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 kBrowserActionBadgeOriginYOffset = 5;
39 static const CGFloat kAnimationDuration = 0.2;
40 static const CGFloat kMinimumDragDistance = 5;
42 @interface BrowserActionButton ()
44 - (void)updateHighlightedState;
45 - (MenuController*)contextMenuController;
46 - (void)menuDidClose:(NSNotification*)notification;
49 // A class to bridge the ToolbarActionViewController and the
50 // BrowserActionButton.
51 class ToolbarActionViewDelegateBridge : public ToolbarActionViewDelegate {
53 ToolbarActionViewDelegateBridge(BrowserActionButton* owner,
54 BrowserActionsController* controller,
55 ToolbarActionViewController* viewController);
56 ~ToolbarActionViewDelegateBridge() override;
58 // Shows the context menu for the owning action.
59 void ShowContextMenu();
61 bool user_shown_popup_visible() const { return user_shown_popup_visible_; }
64 // ToolbarActionViewDelegate:
65 content::WebContents* GetCurrentWebContents() const override;
66 void UpdateState() override;
67 bool IsMenuRunning() const override;
68 void OnPopupShown(bool by_user) override;
69 void OnPopupClosed() override;
71 // A helper method to implement showing the context menu.
72 void DoShowContextMenu();
74 // The owning button. Weak.
75 BrowserActionButton* owner_;
77 // The BrowserActionsController that owns the button. Weak.
78 BrowserActionsController* controller_;
80 // The ToolbarActionViewController for which this is the delegate. Weak.
81 ToolbarActionViewController* viewController_;
83 // Whether or not a popup is visible from a user action.
84 bool user_shown_popup_visible_;
86 // Whether or not a context menu is running (or is in the process of opening).
87 bool contextMenuRunning_;
89 base::WeakPtrFactory<ToolbarActionViewDelegateBridge> weakFactory_;
91 DISALLOW_COPY_AND_ASSIGN(ToolbarActionViewDelegateBridge);
94 ToolbarActionViewDelegateBridge::ToolbarActionViewDelegateBridge(
95 BrowserActionButton* owner,
96 BrowserActionsController* controller,
97 ToolbarActionViewController* viewController)
99 controller_(controller),
100 viewController_(viewController),
101 user_shown_popup_visible_(false),
102 contextMenuRunning_(false),
104 viewController_->SetDelegate(this);
107 ToolbarActionViewDelegateBridge::~ToolbarActionViewDelegateBridge() {
108 viewController_->SetDelegate(nullptr);
111 void ToolbarActionViewDelegateBridge::ShowContextMenu() {
112 // We should only be showing the context menu in this way if we're doing so
113 // for an overflowed action.
114 DCHECK(![owner_ superview]);
116 contextMenuRunning_ = true;
117 WrenchMenuController* wrenchMenuController =
118 [[[BrowserWindowController browserWindowControllerForWindow:
119 [controller_ browser]->window()->GetNativeWindow()]
120 toolbarController] wrenchMenuController];
121 // If the wrench menu is open, we have to first close it. Part of this happens
122 // asynchronously, so we have to use a posted task to open the next menu.
123 if ([wrenchMenuController isMenuOpen])
124 [wrenchMenuController cancel];
126 [controller_ toolbarActionsBar]->PopOutAction(
128 base::Bind(&ToolbarActionViewDelegateBridge::DoShowContextMenu,
129 weakFactory_.GetWeakPtr()));
132 content::WebContents* ToolbarActionViewDelegateBridge::GetCurrentWebContents()
134 return [controller_ currentWebContents];
137 void ToolbarActionViewDelegateBridge::UpdateState() {
138 [owner_ updateState];
141 bool ToolbarActionViewDelegateBridge::IsMenuRunning() const {
142 MenuController* menuController = [owner_ contextMenuController];
143 return contextMenuRunning_ || (menuController && [menuController isMenuOpen]);
146 void ToolbarActionViewDelegateBridge::OnPopupShown(bool by_user) {
148 user_shown_popup_visible_ = true;
149 [owner_ updateHighlightedState];
152 void ToolbarActionViewDelegateBridge::OnPopupClosed() {
153 user_shown_popup_visible_ = false;
154 [owner_ updateHighlightedState];
157 void ToolbarActionViewDelegateBridge::DoShowContextMenu() {
158 // The point the menu shows matches that of the normal wrench menu - that is,
159 // the right-left most corner of the menu is left-aligned with the wrench
160 // button, and the menu is displayed "a little bit" lower. It would be nice to
161 // be able to avoid the magic '5' here, but since it's built into Cocoa, it's
163 NSPoint menuPoint = NSMakePoint(0, NSHeight([owner_ bounds]) + 5);
164 [[owner_ cell] setHighlighted:YES];
165 [[owner_ menu] popUpMenuPositioningItem:nil
168 [[owner_ cell] setHighlighted:NO];
169 contextMenuRunning_ = false;
170 // When the menu closed, the ViewController should have popped itself back in.
171 DCHECK(![controller_ toolbarActionsBar]->popped_out_action());
174 @interface BrowserActionCell (Internals)
175 - (void)drawBadgeWithinFrame:(NSRect)frame
176 forWebContents:(content::WebContents*)webContents;
179 @implementation BrowserActionButton
181 @synthesize isBeingDragged = isBeingDragged_;
184 return [BrowserActionCell class];
187 - (id)initWithFrame:(NSRect)frame
188 viewController:(ToolbarActionViewController*)viewController
189 controller:(BrowserActionsController*)controller {
190 if ((self = [super initWithFrame:frame])) {
191 BrowserActionCell* cell = [[[BrowserActionCell alloc] init] autorelease];
192 // [NSButton setCell:] warns to NOT use setCell: other than in the
193 // initializer of a control. However, we are using a basic
194 // NSButton whose initializer does not take an NSCell as an
195 // object. To honor the assumed semantics, we do nothing with
196 // NSButton between alloc/init and setCell:.
199 browserActionsController_ = controller;
200 viewController_ = viewController;
201 viewControllerDelegate_.reset(
202 new ToolbarActionViewDelegateBridge(self, controller, viewController));
204 [cell setBrowserActionsController:controller];
205 [cell setViewController:viewController_];
207 accessibilitySetOverrideValue:base::SysUTF16ToNSString(
208 viewController_->GetAccessibleName([controller currentWebContents]))
209 forAttribute:NSAccessibilityDescriptionAttribute];
210 [cell setImageID:IDR_BROWSER_ACTION
211 forButtonState:image_button_cell::kDefaultState];
212 [cell setImageID:IDR_BROWSER_ACTION_H
213 forButtonState:image_button_cell::kHoverState];
214 [cell setImageID:IDR_BROWSER_ACTION_P
215 forButtonState:image_button_cell::kPressedState];
216 [cell setImageID:IDR_BROWSER_ACTION
217 forButtonState:image_button_cell::kDisabledState];
220 [self setButtonType:NSMomentaryChangeButton];
221 [self setShowsBorderOnlyWhileMouseInside:YES];
223 moveAnimation_.reset([[NSViewAnimation alloc] init]);
224 [moveAnimation_ gtm_setDuration:kAnimationDuration
225 eventMask:NSLeftMouseUpMask];
226 [moveAnimation_ setAnimationBlockingMode:NSAnimationNonblocking];
234 - (BOOL)acceptsFirstResponder {
238 - (void)rightMouseDown:(NSEvent*)theEvent {
239 // Cocoa doesn't allow menus-running-in-menus, so in order to show the
240 // context menu for an overflowed action, we close the wrench menu and show
241 // the context menu over the wrench (similar to what we do for popups).
242 // Let the main bar's button handle showing the context menu, since the wrench
244 if ([browserActionsController_ isOverflow]) {
245 [browserActionsController_ mainButtonForId:viewController_->GetId()]->
246 viewControllerDelegate_->ShowContextMenu();
248 [super rightMouseDown:theEvent];
252 - (void)mouseDown:(NSEvent*)theEvent {
253 NSPoint location = [self convertPoint:[theEvent locationInWindow]
255 // We don't allow dragging in the overflow container because mouse events
256 // don't work well in menus in Cocoa. Specifically, the minute the mouse
257 // leaves the view, the view stops receiving events. This is bad, because the
258 // mouse can leave the view in many ways (user moves the mouse fast, user
259 // tries to drag the icon to a non-applicable place, like outside the menu,
260 // etc). When the mouse leaves, we get no indication (no mouseUp), so we can't
261 // even handle that case - and are left in the middle of a drag. Instead, we
262 // have to simply disable dragging.
264 // NOTE(devlin): If we use a greedy event loop that consumes all incoming
265 // events (i.e. using [NSWindow nextEventMatchingMask]), we can make this
266 // work. The downside to that is that all other events are lost. Disable this
267 // for now, and revisit it at a later date.
269 if (NSPointInRect(location, [self bounds]) &&
270 ![browserActionsController_ isOverflow]) {
271 dragCouldStart_ = YES;
272 dragStartPoint_ = [self convertPoint:[theEvent locationInWindow]
274 [self updateHighlightedState];
278 - (void)mouseDragged:(NSEvent*)theEvent {
279 if (!dragCouldStart_)
282 NSPoint eventPoint = [theEvent locationInWindow];
283 if (!isBeingDragged_) {
284 // Don't initiate a drag until it moves at least kMinimumDragDistance.
285 NSPoint dragStart = [self convertPoint:dragStartPoint_ toView:nil];
286 CGFloat dx = eventPoint.x - dragStart.x;
287 CGFloat dy = eventPoint.y - dragStart.y;
288 if (dx*dx + dy*dy < kMinimumDragDistance*kMinimumDragDistance)
291 // The start of a drag. Position the button above all others.
292 [[self superview] addSubview:self positioned:NSWindowAbove relativeTo:nil];
294 // We reset the |dragStartPoint_| so that the mouse can always be in the
295 // same point along the button's x axis, and we avoid a "jump" when first
297 dragStartPoint_ = [self convertPoint:eventPoint fromView:nil];
299 isBeingDragged_ = YES;
302 NSRect buttonFrame = [self frame];
303 // The desired x is the current mouse point, minus the original offset of the
304 // mouse into the button.
305 NSPoint localPoint = [[self superview] convertPoint:eventPoint fromView:nil];
306 CGFloat desiredX = localPoint.x - dragStartPoint_.x;
307 // Clamp the button to be within its superview along the X-axis.
308 NSRect containerBounds = [[self superview] bounds];
309 desiredX = std::min(std::max(NSMinX(containerBounds), desiredX),
310 NSMaxX(containerBounds) - NSWidth(buttonFrame));
311 buttonFrame.origin.x = desiredX;
313 // If the button is in the overflow menu, it could move along the y-axis, too.
314 if ([browserActionsController_ isOverflow]) {
315 CGFloat desiredY = localPoint.y - dragStartPoint_.y;
316 desiredY = std::min(std::max(NSMinY(containerBounds), desiredY),
317 NSMaxY(containerBounds) - NSHeight(buttonFrame));
318 buttonFrame.origin.y = desiredY;
321 [self setFrame:buttonFrame];
322 [self setNeedsDisplay:YES];
323 [[NSNotificationCenter defaultCenter]
324 postNotificationName:kBrowserActionButtonDraggingNotification
328 - (void)mouseUp:(NSEvent*)theEvent {
329 dragCouldStart_ = NO;
330 // There are non-drag cases where a mouseUp: may happen
331 // (e.g. mouse-down, cmd-tab to another application, move mouse,
333 NSPoint location = [self convertPoint:[theEvent locationInWindow]
335 if (NSPointInRect(location, [self bounds]) && !isBeingDragged_) {
336 // Only perform the click if we didn't drag the button.
337 [self performClick:self];
339 // Make sure an ESC to end a drag doesn't trigger 2 endDrags.
340 if (isBeingDragged_) {
343 [super mouseUp:theEvent];
346 [self updateHighlightedState];
350 isBeingDragged_ = NO;
351 [[NSNotificationCenter defaultCenter]
352 postNotificationName:kBrowserActionButtonDragEndNotification object:self];
355 - (void)updateHighlightedState {
356 // The button's cell is highlighted if either the popup is showing by a user
357 // action, or the user is about to drag the button, unless the button is
358 // overflowed (in which case it is never highlighted).
359 if ([self superview] && ![browserActionsController_ isOverflow]) {
360 BOOL highlighted = viewControllerDelegate_->user_shown_popup_visible() ||
362 [[self cell] setHighlighted:highlighted];
364 [[self cell] setHighlighted:NO];
368 - (MenuController*)contextMenuController {
369 return contextMenuController_.get();
372 - (void)menuDidClose:(NSNotification*)notification {
373 viewController_->OnContextMenuClosed();
376 - (void)setFrame:(NSRect)frameRect animate:(BOOL)animate {
378 [self setFrame:frameRect];
380 if ([moveAnimation_ isAnimating])
381 [moveAnimation_ stopAnimation];
383 NSDictionary* animationDictionary = @{
384 NSViewAnimationTargetKey : self,
385 NSViewAnimationStartFrameKey : [NSValue valueWithRect:[self frame]],
386 NSViewAnimationEndFrameKey : [NSValue valueWithRect:frameRect]
388 [moveAnimation_ setViewAnimations: @[ animationDictionary ]];
389 [moveAnimation_ startAnimation];
393 - (void)updateState {
394 content::WebContents* webContents =
395 [browserActionsController_ currentWebContents];
399 if (viewController_->WantsToRun(webContents)) {
400 [[self cell] setImageID:IDR_BROWSER_ACTION_R
401 forButtonState:image_button_cell::kDefaultState];
403 [[self cell] setImageID:IDR_BROWSER_ACTION
404 forButtonState:image_button_cell::kDefaultState];
407 base::string16 tooltip = viewController_->GetTooltip(webContents);
408 [self setToolTip:(tooltip.empty() ? nil : base::SysUTF16ToNSString(tooltip))];
410 gfx::Image image = viewController_->GetIcon(webContents);
412 if (!image.IsEmpty())
413 [self setImage:image.ToNSImage()];
415 [self setEnabled:viewController_->IsEnabled(webContents)];
417 [self setNeedsDisplay:YES];
421 // The button is being removed from the toolbar, and the backing controller
422 // will also be removed. Destroy the delegate.
423 // We only need to do this because in Cocoa's memory management, removing the
424 // button from the toolbar doesn't synchronously dealloc it.
425 viewControllerDelegate_.reset();
426 // Also reset the context menu, since it has a dependency on the backing
427 // controller (which owns its model).
428 contextMenuController_.reset();
431 - (BOOL)isAnimating {
432 return [moveAnimation_ isAnimating];
435 - (NSRect)frameAfterAnimation {
436 if ([moveAnimation_ isAnimating]) {
437 NSRect endFrame = [[[[moveAnimation_ viewAnimations] objectAtIndex:0]
438 valueForKey:NSViewAnimationEndFrameKey] rectValue];
445 - (ToolbarActionViewController*)viewController {
446 return viewController_;
449 - (NSImage*)compositedImage {
450 NSRect bounds = [self bounds];
451 NSImage* image = [[[NSImage alloc] initWithSize:bounds.size] autorelease];
454 [[NSColor clearColor] set];
457 NSImage* actionImage = [self image];
458 const NSSize imageSize = [actionImage size];
459 const NSRect imageRect =
460 NSMakeRect(std::floor((NSWidth(bounds) - imageSize.width) / 2.0),
461 std::floor((NSHeight(bounds) - imageSize.height) / 2.0),
462 imageSize.width, imageSize.height);
463 [actionImage drawInRect:imageRect
465 operation:NSCompositeSourceOver
470 bounds.origin.y += kBrowserActionBadgeOriginYOffset;
471 [[self cell] drawBadgeWithinFrame:bounds
473 [browserActionsController_ currentWebContents]];
480 // Hack: Since Cocoa doesn't support menus-running-in-menus (see also comment
481 // in -rightMouseDown:), it doesn't launch the menu for an overflowed action
482 // on a Control-click. Even more unfortunate, it doesn't even pass us the
483 // mouseDown event for control clicks. However, it does call -menuForEvent:,
484 // which in turn calls -menu:, so we can tap in here and show the menu
485 // programmatically for the Control-click case.
486 if ([browserActionsController_ isOverflow] &&
487 ([NSEvent modifierFlags] & NSControlKeyMask)) {
488 [browserActionsController_ mainButtonForId:viewController_->GetId()]->
489 viewControllerDelegate_->ShowContextMenu();
494 if (testContextMenu_) {
495 menu = testContextMenu_;
497 // Make sure we delete any references to an old menu.
498 contextMenuController_.reset();
500 ui::MenuModel* contextMenu = viewController_->GetContextMenu();
502 contextMenuController_.reset(
503 [[MenuController alloc] initWithModel:contextMenu
504 useWithPopUpButtonCell:NO]);
505 menu = [contextMenuController_ menu];
510 [[NSNotificationCenter defaultCenter]
512 selector:@selector(menuDidClose:)
513 name:NSMenuDidEndTrackingNotification
520 #pragma mark Testing Methods
522 - (void)setTestContextMenu:(NSMenu*)testContextMenu {
523 testContextMenu_ = testContextMenu;
528 @implementation BrowserActionCell
530 @synthesize browserActionsController = browserActionsController_;
531 @synthesize viewController = viewController_;
533 - (void)drawBadgeWithinFrame:(NSRect)frame
534 forWebContents:(content::WebContents*)webContents {
535 gfx::CanvasSkiaPaint canvas(frame, false);
536 canvas.set_composite_alpha(true);
537 gfx::Rect boundingRect(NSRectToCGRect(frame));
538 viewController_->PaintExtra(&canvas, boundingRect, webContents);
541 - (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView {
542 gfx::ScopedNSGraphicsContextSaveGState scopedGState;
543 [super drawWithFrame:cellFrame inView:controlView];
544 DCHECK(viewController_);
545 content::WebContents* webContents =
546 [browserActionsController_ currentWebContents];
547 const NSSize imageSize = self.image.size;
548 const NSRect imageRect =
549 NSMakeRect(std::floor((NSWidth(cellFrame) - imageSize.width) / 2.0),
550 std::floor((NSHeight(cellFrame) - imageSize.height) / 2.0),
551 imageSize.width, imageSize.height);
552 [self.image drawInRect:imageRect
554 operation:NSCompositeSourceOver
559 cellFrame.origin.y += kBrowserActionBadgeOriginYOffset;
560 [self drawBadgeWithinFrame:cellFrame
561 forWebContents:webContents];
564 - (ui::ThemeProvider*)themeProviderForWindow:(NSWindow*)window {
565 ui::ThemeProvider* themeProvider = [window themeProvider];
568 [[browserActionsController_ browser]->window()->GetNativeWindow()
570 return themeProvider;