1 // Copyright (c) 2012 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/extensions/browser_action_button.h"
10 #include "base/logging.h"
11 #include "base/memory/weak_ptr.h"
12 #include "base/strings/sys_string_conversions.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/browser_window_controller.h"
17 #import "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h"
18 #import "chrome/browser/ui/cocoa/themed_window.h"
19 #import "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h"
20 #import "chrome/browser/ui/cocoa/wrench_menu/wrench_menu_controller.h"
21 #include "chrome/browser/ui/toolbar/toolbar_action_view_controller.h"
22 #include "chrome/browser/ui/toolbar/toolbar_action_view_delegate.h"
23 #include "chrome/browser/ui/toolbar/toolbar_actions_bar.h"
24 #include "grit/theme_resources.h"
25 #include "skia/ext/skia_utils_mac.h"
26 #import "third_party/google_toolbox_for_mac/src/AppKit/GTMNSAnimation+Duration.h"
27 #import "ui/base/cocoa/menu_controller.h"
28 #include "ui/gfx/canvas_skia_paint.h"
29 #include "ui/gfx/geometry/rect.h"
30 #include "ui/gfx/image/image.h"
31 #include "ui/gfx/scoped_ns_graphics_context_save_gstate_mac.h"
33 NSString* const kBrowserActionButtonDraggingNotification =
34 @"BrowserActionButtonDraggingNotification";
35 NSString* const kBrowserActionButtonDragEndNotification =
36 @"BrowserActionButtonDragEndNotification";
38 static const CGFloat kAnimationDuration = 0.2;
39 static const CGFloat kMinimumDragDistance = 5;
41 @interface BrowserActionButton ()
43 - (void)updateHighlightedState;
44 - (MenuController*)contextMenuController;
45 - (void)menuDidClose:(NSNotification*)notification;
48 // A class to bridge the ToolbarActionViewController and the
49 // BrowserActionButton.
50 class ToolbarActionViewDelegateBridge : public ToolbarActionViewDelegate {
52 ToolbarActionViewDelegateBridge(BrowserActionButton* owner,
53 BrowserActionsController* controller,
54 ToolbarActionViewController* viewController);
55 ~ToolbarActionViewDelegateBridge() override;
57 // Shows the context menu for the owning action.
58 void ShowContextMenu();
60 bool user_shown_popup_visible() const { return user_shown_popup_visible_; }
63 // ToolbarActionViewDelegate:
64 content::WebContents* GetCurrentWebContents() const override;
65 void UpdateState() override;
66 bool IsMenuRunning() const override;
67 void OnPopupShown(bool by_user) override;
68 void OnPopupClosed() override;
70 // A helper method to implement showing the context menu.
71 void DoShowContextMenu();
73 // The owning button. Weak.
74 BrowserActionButton* owner_;
76 // The BrowserActionsController that owns the button. Weak.
77 BrowserActionsController* controller_;
79 // The ToolbarActionViewController for which this is the delegate. Weak.
80 ToolbarActionViewController* viewController_;
82 // Whether or not a popup is visible from a user action.
83 bool user_shown_popup_visible_;
85 // Whether or not a context menu is running (or is in the process of opening).
86 bool contextMenuRunning_;
88 base::WeakPtrFactory<ToolbarActionViewDelegateBridge> weakFactory_;
90 DISALLOW_COPY_AND_ASSIGN(ToolbarActionViewDelegateBridge);
93 ToolbarActionViewDelegateBridge::ToolbarActionViewDelegateBridge(
94 BrowserActionButton* owner,
95 BrowserActionsController* controller,
96 ToolbarActionViewController* viewController)
98 controller_(controller),
99 viewController_(viewController),
100 user_shown_popup_visible_(false),
101 contextMenuRunning_(false),
103 viewController_->SetDelegate(this);
106 ToolbarActionViewDelegateBridge::~ToolbarActionViewDelegateBridge() {
107 viewController_->SetDelegate(nullptr);
110 void ToolbarActionViewDelegateBridge::ShowContextMenu() {
111 // We should only be showing the context menu in this way if we're doing so
112 // for an overflowed action.
113 DCHECK(![owner_ superview]);
115 contextMenuRunning_ = true;
116 WrenchMenuController* wrenchMenuController =
117 [[[BrowserWindowController browserWindowControllerForWindow:
118 [controller_ browser]->window()->GetNativeWindow()]
119 toolbarController] wrenchMenuController];
120 // If the wrench menu is open, we have to first close it. Part of this happens
121 // asynchronously, so we have to use a posted task to open the next menu.
122 if ([wrenchMenuController isMenuOpen])
123 [wrenchMenuController cancel];
125 [controller_ toolbarActionsBar]->PopOutAction(
127 base::Bind(&ToolbarActionViewDelegateBridge::DoShowContextMenu,
128 weakFactory_.GetWeakPtr()));
131 content::WebContents* ToolbarActionViewDelegateBridge::GetCurrentWebContents()
133 return [controller_ currentWebContents];
136 void ToolbarActionViewDelegateBridge::UpdateState() {
137 [owner_ updateState];
140 bool ToolbarActionViewDelegateBridge::IsMenuRunning() const {
141 MenuController* menuController = [owner_ contextMenuController];
142 return contextMenuRunning_ || (menuController && [menuController isMenuOpen]);
145 void ToolbarActionViewDelegateBridge::OnPopupShown(bool by_user) {
147 user_shown_popup_visible_ = true;
148 [owner_ updateHighlightedState];
151 void ToolbarActionViewDelegateBridge::OnPopupClosed() {
152 user_shown_popup_visible_ = false;
153 [owner_ updateHighlightedState];
156 void ToolbarActionViewDelegateBridge::DoShowContextMenu() {
157 // The point the menu shows matches that of the normal wrench menu - that is,
158 // the right-left most corner of the menu is left-aligned with the wrench
159 // button, and the menu is displayed "a little bit" lower. It would be nice to
160 // be able to avoid the magic '5' here, but since it's built into Cocoa, it's
162 NSPoint menuPoint = NSMakePoint(0, NSHeight([owner_ bounds]) + 5);
163 [[owner_ cell] setHighlighted:YES];
164 [[owner_ menu] popUpMenuPositioningItem:nil
167 [[owner_ cell] setHighlighted:NO];
168 contextMenuRunning_ = false;
169 // When the menu closed, the ViewController should have popped itself back in.
170 DCHECK(![controller_ toolbarActionsBar]->popped_out_action());
173 @implementation BrowserActionButton
175 @synthesize isBeingDragged = isBeingDragged_;
178 return [BrowserActionCell class];
181 - (id)initWithFrame:(NSRect)frame
182 viewController:(ToolbarActionViewController*)viewController
183 controller:(BrowserActionsController*)controller {
184 if ((self = [super initWithFrame:frame])) {
185 BrowserActionCell* cell = [[[BrowserActionCell alloc] init] autorelease];
186 // [NSButton setCell:] warns to NOT use setCell: other than in the
187 // initializer of a control. However, we are using a basic
188 // NSButton whose initializer does not take an NSCell as an
189 // object. To honor the assumed semantics, we do nothing with
190 // NSButton between alloc/init and setCell:.
193 browserActionsController_ = controller;
194 viewController_ = viewController;
195 viewControllerDelegate_.reset(
196 new ToolbarActionViewDelegateBridge(self, controller, viewController));
198 [cell setBrowserActionsController:controller];
200 accessibilitySetOverrideValue:base::SysUTF16ToNSString(
201 viewController_->GetAccessibleName([controller currentWebContents]))
202 forAttribute:NSAccessibilityDescriptionAttribute];
203 [cell setImageID:IDR_BROWSER_ACTION
204 forButtonState:image_button_cell::kDefaultState];
205 [cell setImageID:IDR_BROWSER_ACTION_H
206 forButtonState:image_button_cell::kHoverState];
207 [cell setImageID:IDR_BROWSER_ACTION_P
208 forButtonState:image_button_cell::kPressedState];
209 [cell setImageID:IDR_BROWSER_ACTION
210 forButtonState:image_button_cell::kDisabledState];
213 [self setButtonType:NSMomentaryChangeButton];
214 [self setShowsBorderOnlyWhileMouseInside:YES];
216 moveAnimation_.reset([[NSViewAnimation alloc] init]);
217 [moveAnimation_ gtm_setDuration:kAnimationDuration
218 eventMask:NSLeftMouseUpMask];
219 [moveAnimation_ setAnimationBlockingMode:NSAnimationNonblocking];
227 - (BOOL)acceptsFirstResponder {
231 - (void)rightMouseDown:(NSEvent*)theEvent {
232 // Cocoa doesn't allow menus-running-in-menus, so in order to show the
233 // context menu for an overflowed action, we close the wrench menu and show
234 // the context menu over the wrench (similar to what we do for popups).
235 // Let the main bar's button handle showing the context menu, since the wrench
237 if ([browserActionsController_ isOverflow]) {
238 [browserActionsController_ mainButtonForId:viewController_->GetId()]->
239 viewControllerDelegate_->ShowContextMenu();
241 [super rightMouseDown:theEvent];
245 - (void)mouseDown:(NSEvent*)theEvent {
246 NSPoint location = [self convertPoint:[theEvent locationInWindow]
248 // We don't allow dragging in the overflow container because mouse events
249 // don't work well in menus in Cocoa. Specifically, the minute the mouse
250 // leaves the view, the view stops receiving events. This is bad, because the
251 // mouse can leave the view in many ways (user moves the mouse fast, user
252 // tries to drag the icon to a non-applicable place, like outside the menu,
253 // etc). When the mouse leaves, we get no indication (no mouseUp), so we can't
254 // even handle that case - and are left in the middle of a drag. Instead, we
255 // have to simply disable dragging.
257 // NOTE(devlin): If we use a greedy event loop that consumes all incoming
258 // events (i.e. using [NSWindow nextEventMatchingMask]), we can make this
259 // work. The downside to that is that all other events are lost. Disable this
260 // for now, and revisit it at a later date.
262 if (NSPointInRect(location, [self bounds]) &&
263 ![browserActionsController_ isOverflow]) {
264 dragCouldStart_ = YES;
265 dragStartPoint_ = [self convertPoint:[theEvent locationInWindow]
267 [self updateHighlightedState];
271 - (void)mouseDragged:(NSEvent*)theEvent {
272 if (!dragCouldStart_)
275 NSPoint eventPoint = [theEvent locationInWindow];
276 if (!isBeingDragged_) {
277 // Don't initiate a drag until it moves at least kMinimumDragDistance.
278 NSPoint dragStart = [self convertPoint:dragStartPoint_ toView:nil];
279 CGFloat dx = eventPoint.x - dragStart.x;
280 CGFloat dy = eventPoint.y - dragStart.y;
281 if (dx*dx + dy*dy < kMinimumDragDistance*kMinimumDragDistance)
284 // The start of a drag. Position the button above all others.
285 [[self superview] addSubview:self positioned:NSWindowAbove relativeTo:nil];
287 // We reset the |dragStartPoint_| so that the mouse can always be in the
288 // same point along the button's x axis, and we avoid a "jump" when first
290 dragStartPoint_ = [self convertPoint:eventPoint fromView:nil];
292 isBeingDragged_ = YES;
295 NSRect buttonFrame = [self frame];
296 // The desired x is the current mouse point, minus the original offset of the
297 // mouse into the button.
298 NSPoint localPoint = [[self superview] convertPoint:eventPoint fromView:nil];
299 CGFloat desiredX = localPoint.x - dragStartPoint_.x;
300 // Clamp the button to be within its superview along the X-axis.
301 NSRect containerBounds = [[self superview] bounds];
302 desiredX = std::min(std::max(NSMinX(containerBounds), desiredX),
303 NSMaxX(containerBounds) - NSWidth(buttonFrame));
304 buttonFrame.origin.x = desiredX;
306 // If the button is in the overflow menu, it could move along the y-axis, too.
307 if ([browserActionsController_ isOverflow]) {
308 CGFloat desiredY = localPoint.y - dragStartPoint_.y;
309 desiredY = std::min(std::max(NSMinY(containerBounds), desiredY),
310 NSMaxY(containerBounds) - NSHeight(buttonFrame));
311 buttonFrame.origin.y = desiredY;
314 [self setFrame:buttonFrame];
315 [self setNeedsDisplay:YES];
316 [[NSNotificationCenter defaultCenter]
317 postNotificationName:kBrowserActionButtonDraggingNotification
321 - (void)mouseUp:(NSEvent*)theEvent {
322 dragCouldStart_ = NO;
323 // There are non-drag cases where a mouseUp: may happen
324 // (e.g. mouse-down, cmd-tab to another application, move mouse,
326 NSPoint location = [self convertPoint:[theEvent locationInWindow]
328 // Only perform the click if we didn't drag the button.
329 if (NSPointInRect(location, [self bounds]) && !isBeingDragged_) {
330 // There's also a chance that the action is disabled, and the left click
331 // should show the context menu.
332 if (!viewController_->IsEnabled(
333 [browserActionsController_ currentWebContents]) &&
334 viewController_->DisabledClickOpensMenu()) {
335 // No menus-in-menus; see comment in -rightMouseDown:.
336 if ([browserActionsController_ isOverflow]) {
337 [browserActionsController_ mainButtonForId:viewController_->GetId()]->
338 viewControllerDelegate_->ShowContextMenu();
340 [NSMenu popUpContextMenu:[self menu] withEvent:theEvent forView:self];
343 [self performClick:self];
346 // Make sure an ESC to end a drag doesn't trigger 2 endDrags.
347 if (isBeingDragged_) {
350 [super mouseUp:theEvent];
353 [self updateHighlightedState];
357 isBeingDragged_ = NO;
358 [[NSNotificationCenter defaultCenter]
359 postNotificationName:kBrowserActionButtonDragEndNotification object:self];
362 - (void)updateHighlightedState {
363 // The button's cell is highlighted if either the popup is showing by a user
364 // action, or the user is about to drag the button, unless the button is
365 // overflowed (in which case it is never highlighted).
366 if ([self superview] && ![browserActionsController_ isOverflow]) {
367 BOOL highlighted = viewControllerDelegate_->user_shown_popup_visible() ||
369 [[self cell] setHighlighted:highlighted];
371 [[self cell] setHighlighted:NO];
375 - (MenuController*)contextMenuController {
376 return contextMenuController_.get();
379 - (void)menuDidClose:(NSNotification*)notification {
380 viewController_->OnContextMenuClosed();
383 - (void)setFrame:(NSRect)frameRect animate:(BOOL)animate {
385 [self setFrame:frameRect];
387 if ([moveAnimation_ isAnimating])
388 [moveAnimation_ stopAnimation];
390 NSDictionary* animationDictionary = @{
391 NSViewAnimationTargetKey : self,
392 NSViewAnimationStartFrameKey : [NSValue valueWithRect:[self frame]],
393 NSViewAnimationEndFrameKey : [NSValue valueWithRect:frameRect]
395 [moveAnimation_ setViewAnimations: @[ animationDictionary ]];
396 [moveAnimation_ startAnimation];
400 - (void)updateState {
401 content::WebContents* webContents =
402 [browserActionsController_ currentWebContents];
406 base::string16 tooltip = viewController_->GetTooltip(webContents);
407 [self setToolTip:(tooltip.empty() ? nil : base::SysUTF16ToNSString(tooltip))];
410 viewController_->GetIcon(webContents, gfx::Size([self frame].size));
412 if (!image.IsEmpty())
413 [self setImage:image.ToNSImage()];
415 BOOL enabled = viewController_->IsEnabled(webContents) ||
416 viewController_->DisabledClickOpensMenu();
417 [self setEnabled:enabled];
419 [self setNeedsDisplay:YES];
423 // The button is being removed from the toolbar, and the backing controller
424 // will also be removed. Destroy the delegate.
425 // We only need to do this because in Cocoa's memory management, removing the
426 // button from the toolbar doesn't synchronously dealloc it.
427 viewControllerDelegate_.reset();
428 // Also reset the context menu, since it has a dependency on the backing
429 // controller (which owns its model).
430 contextMenuController_.reset();
433 - (BOOL)isAnimating {
434 return [moveAnimation_ isAnimating];
437 - (NSRect)frameAfterAnimation {
438 if ([moveAnimation_ isAnimating]) {
439 NSRect endFrame = [[[[moveAnimation_ viewAnimations] objectAtIndex:0]
440 valueForKey:NSViewAnimationEndFrameKey] rectValue];
447 - (ToolbarActionViewController*)viewController {
448 return viewController_;
451 - (NSImage*)compositedImage {
452 NSRect bounds = [self bounds];
453 NSImage* image = [[[NSImage alloc] initWithSize:bounds.size] autorelease];
456 [[NSColor clearColor] set];
459 NSImage* actionImage = [self image];
460 const NSSize imageSize = [actionImage size];
461 const NSRect imageRect =
462 NSMakeRect(std::floor((NSWidth(bounds) - imageSize.width) / 2.0),
463 std::floor((NSHeight(bounds) - imageSize.height) / 2.0),
464 imageSize.width, imageSize.height);
465 [actionImage drawInRect:imageRect
467 operation:NSCompositeSourceOver
477 // Hack: Since Cocoa doesn't support menus-running-in-menus (see also comment
478 // in -rightMouseDown:), it doesn't launch the menu for an overflowed action
479 // on a Control-click. Even more unfortunate, it doesn't even pass us the
480 // mouseDown event for control clicks. However, it does call -menuForEvent:,
481 // which in turn calls -menu:, so we can tap in here and show the menu
482 // programmatically for the Control-click case.
483 if ([browserActionsController_ isOverflow] &&
484 ([NSEvent modifierFlags] & NSControlKeyMask)) {
485 [browserActionsController_ mainButtonForId:viewController_->GetId()]->
486 viewControllerDelegate_->ShowContextMenu();
491 if (testContextMenu_) {
492 menu = testContextMenu_;
494 // Make sure we delete any references to an old menu.
495 contextMenuController_.reset();
497 ui::MenuModel* contextMenu = viewController_->GetContextMenu();
499 contextMenuController_.reset(
500 [[MenuController alloc] initWithModel:contextMenu
501 useWithPopUpButtonCell:NO]);
502 menu = [contextMenuController_ menu];
507 [[NSNotificationCenter defaultCenter]
509 selector:@selector(menuDidClose:)
510 name:NSMenuDidEndTrackingNotification
517 #pragma mark Testing Methods
519 - (void)setTestContextMenu:(NSMenu*)testContextMenu {
520 testContextMenu_ = testContextMenu;
523 - (BOOL)wantsToRunForTesting {
524 return viewController_->WantsToRun(
525 [browserActionsController_ currentWebContents]);
530 @implementation BrowserActionCell
532 @synthesize browserActionsController = browserActionsController_;
534 - (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView {
535 gfx::ScopedNSGraphicsContextSaveGState scopedGState;
536 [super drawWithFrame:cellFrame inView:controlView];
538 const NSSize imageSize = self.image.size;
539 const NSRect imageRect =
540 NSMakeRect(std::floor((NSWidth(cellFrame) - imageSize.width) / 2.0),
541 std::floor((NSHeight(cellFrame) - imageSize.height) / 2.0),
542 imageSize.width, imageSize.height);
544 [self.image drawInRect:imageRect
546 operation:NSCompositeSourceOver
552 - (void)drawFocusRingMaskWithFrame:(NSRect)cellFrame inView:(NSView*)view {
553 // Match the hover image's bezel.
554 [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(cellFrame, 2, 2)
559 - (ui::ThemeProvider*)themeProviderForWindow:(NSWindow*)window {
560 ui::ThemeProvider* themeProvider = [window themeProvider];
563 [[browserActionsController_ browser]->window()->GetNativeWindow()
565 return themeProvider;