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();
137 [browserActionsController_ browserWillBeDestroyed];
142 - (void)addItemToMenu:(NSMenu*)menu
143 atIndex:(NSInteger)index
144 fromModel:(ui::MenuModel*)model {
145 // Non-button item types should be built as normal items, with the exception
146 // of the extensions overflow menu.
147 int command_id = model->GetCommandIdAt(index);
148 if (model->GetTypeAt(index) != ui::MenuModel::TYPE_BUTTON_ITEM &&
149 command_id != IDC_EXTENSIONS_OVERFLOW_MENU) {
150 [super addItemToMenu:menu
156 // Handle the special-cased menu items.
157 base::scoped_nsobject<NSMenuItem> customItem(
158 [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]);
159 MenuTrackedRootView* view = nil;
160 switch (command_id) {
161 case IDC_EXTENSIONS_OVERFLOW_MENU: {
162 browserActionsMenuItem_ = customItem.get();
163 view = [buttonViewController_ toolbarActionsOverflowItem];
164 BrowserActionsContainerView* containerView =
165 [buttonViewController_ overflowActionsContainerView];
167 // The overflow browser actions container can't function properly without
168 // a main counterpart, so if the browser window hasn't initialized, abort.
169 // (This is fine because we re-populate the wrench menu each time before
171 if (!browser_->window())
174 BrowserActionsController* mainController =
175 [[[BrowserWindowController browserWindowControllerForWindow:browser_->
176 window()->GetNativeWindow()] toolbarController]
177 browserActionsController];
178 browserActionsController_.reset(
179 [[BrowserActionsController alloc]
180 initWithBrowser:browser_
181 containerView:containerView
182 mainController:mainController]);
186 view = [buttonViewController_ editItem];
189 view = [buttonViewController_ zoomItem];
196 [customItem setView:view];
197 [view setMenuItem:customItem];
198 [self adjustPositioning];
199 [menu insertItem:customItem.get() atIndex:index];
202 - (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item {
203 const BOOL enabled = [super validateUserInterfaceItem:item];
205 NSMenuItem* menuItem = (id)item;
206 ui::MenuModel* model =
207 static_cast<ui::MenuModel*>(
208 [[menuItem representedObject] pointerValue]);
210 // The section headers in the recent tabs submenu should be bold and black if
211 // a font list is specified for the items (bold is already applied in the
212 // |MenuController| as the font list returned by |GetLabelFontListAt| is
214 if (model && model == [self recentTabsMenuModel]) {
215 if (model->GetLabelFontListAt([item tag])) {
216 DCHECK([menuItem attributedTitle]);
217 base::scoped_nsobject<NSMutableAttributedString> title(
218 [[NSMutableAttributedString alloc]
219 initWithAttributedString:[menuItem attributedTitle]]);
220 [title addAttribute:NSForegroundColorAttributeName
221 value:[NSColor blackColor]
222 range:NSMakeRange(0, [title length])];
223 [menuItem setAttributedTitle:title.get()];
225 // Not a section header. Add a tooltip with the title and the URL.
227 base::string16 title;
228 if ([self recentTabsMenuModel]->GetURLAndTitleForItemAtIndex(
229 [item tag], &url, &title)) {
230 [menuItem setToolTip:
231 cocoa_l10n_util::TooltipForURLAndTitle(
232 base::SysUTF8ToNSString(url), base::SysUTF16ToNSString(title))];
240 - (NSMenu*)bookmarkSubMenu {
241 NSString* title = l10n_util::GetNSStringWithFixup(IDS_BOOKMARKS_MENU);
242 return [[[self menu] itemWithTitle:title] submenu];
245 - (void)updateBookmarkSubMenu {
246 NSMenu* bookmarkMenu = [self bookmarkSubMenu];
247 DCHECK(bookmarkMenu);
249 bookmarkMenuBridge_.reset(
250 new BookmarkMenuBridge([self wrenchMenuModel]->browser()->profile(),
254 - (void)updateBrowserActionsSubmenu {
255 MenuTrackedRootView* view =
256 [buttonViewController_ toolbarActionsOverflowItem];
257 BrowserActionsContainerView* containerView =
258 [buttonViewController_ overflowActionsContainerView];
260 // Find the preferred container size for the menu width.
261 int menuWidth = [[self menu] size].width;
262 int maxContainerWidth = menuWidth - kLeftPadding - kRightPadding;
263 // Don't let the menu change sizes on us. (We lift this restriction every time
264 // the menu updates, so if something changes, this won't leave us with an
266 [[self menu] setMinimumWidth:menuWidth];
267 gfx::Size preferredContainerSize =
268 [browserActionsController_ sizeForOverflowWidth:maxContainerWidth];
270 // Set the origins and preferred size for the container.
271 // View hierarchy is as follows (from parent > child):
272 // |view| > |anonymous view| > containerView. We have to set the origin
273 // and size of each for it display properly.
274 // The parent views each have a size of the full width of the menu, so we can
275 // properly position the container.
276 NSSize parentSize = NSMakeSize(menuWidth, preferredContainerSize.height());
277 [view setFrameSize:parentSize];
278 [[containerView superview] setFrameSize:parentSize];
280 // The container view gets its preferred size.
281 [containerView setFrameSize:NSMakeSize(preferredContainerSize.width(),
282 preferredContainerSize.height())];
283 [browserActionsController_ update];
285 [view setFrameOrigin:NSZeroPoint];
286 [[containerView superview] setFrameOrigin:NSZeroPoint];
287 [containerView setFrameOrigin:NSMakePoint(kLeftPadding, 0)];
290 - (void)menuWillOpen:(NSMenu*)menu {
291 [super menuWillOpen:menu];
293 NSString* title = base::SysUTF16ToNSString(
294 [self wrenchMenuModel]->GetLabelForCommandId(IDC_ZOOM_PERCENT_DISPLAY));
295 [[[buttonViewController_ zoomItem] viewWithTag:IDC_ZOOM_PERCENT_DISPLAY]
297 content::RecordAction(UserMetricsAction("ShowAppMenu"));
299 NSImage* icon = [self wrenchMenuModel]->browser()->window()->IsFullscreen() ?
300 [NSImage imageNamed:NSImageNameExitFullScreenTemplate] :
301 [NSImage imageNamed:NSImageNameEnterFullScreenTemplate];
302 [[buttonViewController_ zoomFullScreen] setImage:icon];
305 - (void)menuNeedsUpdate:(NSMenu*)menu {
306 // First empty out the menu and create a new model.
307 [self removeAllItems:menu];
309 [menu setMinimumWidth:0];
311 // Create a new menu, which cannot be swapped because the tracking is about to
312 // start, so simply copy the items.
313 NSMenu* newMenu = [self menuFromModel:model_];
314 NSArray* itemArray = [newMenu itemArray];
315 [self removeAllItems:newMenu];
316 for (NSMenuItem* item in itemArray) {
320 [self updateRecentTabsSubmenu];
321 [self updateBookmarkSubMenu];
322 [self updateBrowserActionsSubmenu];
325 // Used to dispatch commands from the Wrench menu. The custom items within the
326 // menu cannot be hooked up directly to First Responder because the window in
327 // which the controls reside is not the BrowserWindowController, but a
328 // NSCarbonMenuWindow; this screws up the typical |-commandDispatch:| system.
329 - (IBAction)dispatchWrenchMenuCommand:(id)sender {
330 NSInteger tag = [sender tag];
331 if (sender == [buttonViewController_ zoomPlus] ||
332 sender == [buttonViewController_ zoomMinus]) {
333 // Do a direct dispatch rather than scheduling on the outermost run loop,
334 // which would not get hit until after the menu had closed.
335 [self performCommandDispatch:[NSNumber numberWithInt:tag]];
337 // The zoom buttons should not close the menu if opened sticky.
338 if ([sender respondsToSelector:@selector(isTracking)] &&
339 [sender performSelector:@selector(isTracking)]) {
340 [menu_ cancelTracking];
343 // The custom views within the Wrench menu are abnormal and keep the menu
344 // open after a target-action. Close the menu manually.
345 [menu_ cancelTracking];
347 // Executing certain commands from the nested run loop of the menu can lead
348 // to wonky behavior (e.g. http://crbug.com/49716). To avoid this, schedule
349 // the dispatch on the outermost run loop.
350 [self performSelector:@selector(performCommandDispatch:)
351 withObject:[NSNumber numberWithInt:tag]
356 // Used to perform the actual dispatch on the outermost runloop.
357 - (void)performCommandDispatch:(NSNumber*)tag {
358 [self wrenchMenuModel]->ExecuteCommand([tag intValue], 0);
361 - (WrenchMenuModel*)wrenchMenuModel {
362 // Don't use |wrenchMenuModel_| so that a test can override the generic one.
363 return static_cast<WrenchMenuModel*>(model_);
366 - (void)updateRecentTabsSubmenu {
367 ui::MenuModel* model = [self recentTabsMenuModel];
369 recentTabsMenuModelDelegate_.reset(
370 new RecentTabsMenuModelDelegate(model, [self recentTabsSubmenu]));
374 - (BrowserActionsController*)browserActionsController {
375 return browserActionsController_.get();
378 - (void)createModel {
380 recentTabsMenuModelDelegate_.reset();
381 wrenchMenuModel_.reset(
382 new WrenchMenuModel(acceleratorDelegate_.get(), browser_));
383 [self setModel:wrenchMenuModel_.get()];
385 buttonViewController_.reset(
386 [[WrenchMenuButtonViewController alloc] initWithController:self]);
387 [buttonViewController_ view];
389 // See comment in containerSuperviewFrameChanged:.
390 NSView* containerSuperview =
391 [[buttonViewController_ overflowActionsContainerView] superview];
392 [containerSuperview setPostsFrameChangedNotifications:YES];
395 // Fit the localized strings into the Cut/Copy/Paste control, then resize the
396 // whole menu item accordingly.
397 - (void)adjustPositioning {
398 const CGFloat kButtonPadding = 12;
401 // Go through the three buttons from right-to-left, adjusting the size to fit
402 // the localized strings while keeping them all aligned on their horizontal
404 NSButton* views[] = {
405 [buttonViewController_ editPaste],
406 [buttonViewController_ editCopy],
407 [buttonViewController_ editCut]
409 for (size_t i = 0; i < arraysize(views); ++i) {
410 NSButton* button = views[i];
411 CGFloat originalWidth = NSWidth([button frame]);
413 // Do not let |-sizeToFit| change the height of the button.
414 NSSize size = [button frame].size;
416 size.width = [button frame].size.width + kButtonPadding;
417 [button setFrameSize:size];
419 CGFloat newWidth = size.width;
420 delta += newWidth - originalWidth;
422 NSRect frame = [button frame];
423 frame.origin.x -= delta;
424 [button setFrame:frame];
427 // Resize the menu item by the total amound the buttons changed so that the
428 // spacing between the buttons and the title remains the same.
429 NSRect itemFrame = [[buttonViewController_ editItem] frame];
430 itemFrame.size.width += delta;
431 [[buttonViewController_ editItem] setFrame:itemFrame];
433 // Also resize the superview of the buttons, which is an NSView used to slide
434 // when the item title is too big and GTM resizes it.
435 NSRect parentFrame = [[[buttonViewController_ editCut] superview] frame];
436 parentFrame.size.width += delta;
437 parentFrame.origin.x -= delta;
438 [[[buttonViewController_ editCut] superview] setFrame:parentFrame];
441 - (NSButton*)zoomDisplay {
442 return [buttonViewController_ zoomDisplay];
445 - (void)menu:(NSMenu*)menu willHighlightItem:(NSMenuItem*)item {
446 if (browserActionsController_.get()) {
447 [browserActionsController_ setFocusedInOverflow:
448 (item == browserActionsMenuItem_)];
452 // -[NSMenu removeAllItems] is only available on 10.6+.
453 - (void)removeAllItems:(NSMenu*)menu {
454 while ([menu numberOfItems]) {
455 [menu removeItemAtIndex:0];
459 - (NSMenu*)recentTabsSubmenu {
460 NSString* title = l10n_util::GetNSStringWithFixup(IDS_RECENT_TABS_MENU);
461 return [[[self menu] itemWithTitle:title] submenu];
464 // The recent tabs menu model is recognized by the existence of either the
465 // kRecentlyClosedHeaderCommandId or the kDisabledRecentlyClosedHeaderCommandId.
466 - (RecentTabsSubMenuModel*)recentTabsMenuModel {
468 // Start searching at the wrench menu model level, |model| will be updated
469 // only if the command we're looking for is found in one of the [sub]menus.
470 ui::MenuModel* model = [self wrenchMenuModel];
471 if (ui::MenuModel::GetModelAndIndexForCommandId(
472 RecentTabsSubMenuModel::kRecentlyClosedHeaderCommandId, &model,
474 return static_cast<RecentTabsSubMenuModel*>(model);
476 if (ui::MenuModel::GetModelAndIndexForCommandId(
477 RecentTabsSubMenuModel::kDisabledRecentlyClosedHeaderCommandId,
479 return static_cast<RecentTabsSubMenuModel*>(model);
484 // This overrdies the parent class to return a custom width for recent tabs
486 - (int)maxWidthForMenuModel:(ui::MenuModel*)model
487 modelIndex:(int)modelIndex {
488 RecentTabsSubMenuModel* recentTabsMenuModel = [self recentTabsMenuModel];
489 if (recentTabsMenuModel && recentTabsMenuModel == model) {
490 return recentTabsMenuModel->GetMaxWidthForItemAtIndex(modelIndex);
495 @end // @implementation WrenchMenuController
497 ////////////////////////////////////////////////////////////////////////////////
499 @interface WrenchMenuButtonViewController ()
500 - (void)containerSuperviewFrameChanged:(NSNotification*)notification;
503 @implementation WrenchMenuButtonViewController
505 @synthesize editItem = editItem_;
506 @synthesize editCut = editCut_;
507 @synthesize editCopy = editCopy_;
508 @synthesize editPaste = editPaste_;
509 @synthesize zoomItem = zoomItem_;
510 @synthesize zoomPlus = zoomPlus_;
511 @synthesize zoomDisplay = zoomDisplay_;
512 @synthesize zoomMinus = zoomMinus_;
513 @synthesize zoomFullScreen = zoomFullScreen_;
514 @synthesize toolbarActionsOverflowItem = toolbarActionsOverflowItem_;
515 @synthesize overflowActionsContainerView = overflowActionsContainerView_;
517 - (id)initWithController:(WrenchMenuController*)controller {
518 if ((self = [super initWithNibName:@"WrenchMenu"
519 bundle:base::mac::FrameworkBundle()])) {
520 controller_ = controller;
521 [[NSNotificationCenter defaultCenter]
523 selector:@selector(containerSuperviewFrameChanged:)
524 name:NSViewFrameDidChangeNotification
525 object:[overflowActionsContainerView_ superview]];
531 [[NSNotificationCenter defaultCenter] removeObserver:self];
535 - (IBAction)dispatchWrenchMenuCommand:(id)sender {
536 [controller_ dispatchWrenchMenuCommand:sender];
539 - (void)containerSuperviewFrameChanged:(NSNotification*)notification {
540 // AppKit menus were probably never designed with a view like the browser
541 // actions container in mind, and, as a result, we come across a few oddities.
542 // One of these is that the container's superview will, on some versions of
543 // OSX, change frame position sometime after the the menu begins tracking
544 // (and thus, after all our ability to adjust it normally). Throw in the
545 // towel, and simply don't let the frame move from where it's supposed to be.
546 // TODO(devlin): Yet another Cocoa hack. It'd be good to find a workaround,
547 // but unlikely unless we replace the Cocoa menu implementation.
548 NSView* containerSuperview = [overflowActionsContainerView_ superview];
549 if (NSMinX([containerSuperview frame]) != 0)
550 [containerSuperview setFrameOrigin:NSZeroPoint];
553 @end // @implementation WrenchMenuButtonViewController