Popular sites on the NTP: check that experiment group StartsWith (rather than IS...
[chromium-blink-merge.git] / chrome / browser / ui / cocoa / extensions / browser_action_button.mm
blob893584615417c1adc618b4100274167d0d3dc2a5
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"
7 #include <algorithm>
8 #include <cmath>
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 ()
42 - (void)endDrag;
43 - (void)updateHighlightedState;
44 - (MenuController*)contextMenuController;
45 - (void)menuDidClose:(NSNotification*)notification;
46 @end
48 // A class to bridge the ToolbarActionViewController and the
49 // BrowserActionButton.
50 class ToolbarActionViewDelegateBridge : public ToolbarActionViewDelegate {
51  public:
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_; }
62  private:
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)
97     : owner_(owner),
98       controller_(controller),
99       viewController_(viewController),
100       user_shown_popup_visible_(false),
101       contextMenuRunning_(false),
102       weakFactory_(this) {
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(
126       viewController_,
127       base::Bind(&ToolbarActionViewDelegateBridge::DoShowContextMenu,
128                  weakFactory_.GetWeakPtr()));
131 content::WebContents* ToolbarActionViewDelegateBridge::GetCurrentWebContents()
132     const {
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) {
146   if (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
161   // not too hopeful.
162   NSPoint menuPoint = NSMakePoint(0, NSHeight([owner_ bounds]) + 5);
163   [[owner_ cell] setHighlighted:YES];
164   [[owner_ menu] popUpMenuPositioningItem:nil
165                                atLocation:menuPoint
166                                    inView:owner_];
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_;
177 + (Class)cellClass {
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:.
191     [self setCell:cell];
193     browserActionsController_ = controller;
194     viewController_ = viewController;
195     viewControllerDelegate_.reset(
196         new ToolbarActionViewDelegateBridge(self, controller, viewController));
198     [cell setBrowserActionsController:controller];
199     [cell
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];
212     [self setTitle:@""];
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];
221     [self updateState];
222   }
224   return self;
227 - (BOOL)acceptsFirstResponder {
228   return YES;
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
236   // menu will close..
237   if ([browserActionsController_ isOverflow]) {
238     [browserActionsController_ mainButtonForId:viewController_->GetId()]->
239         viewControllerDelegate_->ShowContextMenu();
240   } else {
241     [super rightMouseDown:theEvent];
242   }
245 - (void)mouseDown:(NSEvent*)theEvent {
246   NSPoint location = [self convertPoint:[theEvent locationInWindow]
247                                fromView:nil];
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.
256   //
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]
266                                 fromView:nil];
267     [self updateHighlightedState];
268   }
271 - (void)mouseDragged:(NSEvent*)theEvent {
272   if (!dragCouldStart_)
273     return;
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)
282       return;
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
289     // starting to drag.
290     dragStartPoint_ = [self convertPoint:eventPoint fromView:nil];
292     isBeingDragged_ = YES;
293   }
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;
312   }
314   [self setFrame:buttonFrame];
315   [self setNeedsDisplay:YES];
316   [[NSNotificationCenter defaultCenter]
317       postNotificationName:kBrowserActionButtonDraggingNotification
318       object:self];
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,
325   // mouse-up).
326   NSPoint location = [self convertPoint:[theEvent locationInWindow]
327                                fromView:nil];
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();
339       } else {
340         [NSMenu popUpContextMenu:[self menu] withEvent:theEvent forView:self];
341       }
342     } else {
343       [self performClick:self];
344     }
345   } else {
346     // Make sure an ESC to end a drag doesn't trigger 2 endDrags.
347     if (isBeingDragged_) {
348       [self endDrag];
349     } else {
350       [super mouseUp:theEvent];
351     }
352   }
353   [self updateHighlightedState];
356 - (void)endDrag {
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() ||
368         dragCouldStart_;
369     [[self cell] setHighlighted:highlighted];
370   } else {
371     [[self cell] setHighlighted:NO];
372   }
375 - (MenuController*)contextMenuController {
376   return contextMenuController_.get();
379 - (void)menuDidClose:(NSNotification*)notification {
380   viewController_->OnContextMenuClosed();
383 - (void)setFrame:(NSRect)frameRect animate:(BOOL)animate {
384   if (!animate) {
385     [self setFrame:frameRect];
386   } else {
387     if ([moveAnimation_ isAnimating])
388       [moveAnimation_ stopAnimation];
390     NSDictionary* animationDictionary = @{
391       NSViewAnimationTargetKey : self,
392       NSViewAnimationStartFrameKey : [NSValue valueWithRect:[self frame]],
393       NSViewAnimationEndFrameKey : [NSValue valueWithRect:frameRect]
394     };
395     [moveAnimation_ setViewAnimations: @[ animationDictionary ]];
396     [moveAnimation_ startAnimation];
397   }
400 - (void)updateState {
401   content::WebContents* webContents =
402       [browserActionsController_ currentWebContents];
403   if (!webContents)
404     return;
406   base::string16 tooltip = viewController_->GetTooltip(webContents);
407   [self setToolTip:(tooltip.empty() ? nil : base::SysUTF16ToNSString(tooltip))];
409   gfx::Image image =
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];
422 - (void)onRemoved {
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];
441     return endFrame;
442   } else {
443     return [self frame];
444   }
447 - (ToolbarActionViewController*)viewController {
448   return viewController_;
451 - (NSImage*)compositedImage {
452   NSRect bounds = [self bounds];
453   NSImage* image = [[[NSImage alloc] initWithSize:bounds.size] autorelease];
454   [image lockFocus];
456   [[NSColor clearColor] set];
457   NSRectFill(bounds);
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
466                  fromRect:NSZeroRect
467                 operation:NSCompositeSourceOver
468                  fraction:1.0
469            respectFlipped:YES
470                     hints:nil];
472   [image unlockFocus];
473   return image;
476 - (NSMenu*)menu {
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();
487     return nil;
488   }
490   NSMenu* menu = nil;
491   if (testContextMenu_) {
492     menu = testContextMenu_;
493   } else {
494     // Make sure we delete any references to an old menu.
495     contextMenuController_.reset();
497     ui::MenuModel* contextMenu = viewController_->GetContextMenu();
498     if (contextMenu) {
499       contextMenuController_.reset(
500           [[MenuController alloc] initWithModel:contextMenu
501                          useWithPopUpButtonCell:NO]);
502       menu = [contextMenuController_ menu];
503     }
504   }
506   if (menu) {
507     [[NSNotificationCenter defaultCenter]
508         addObserver:self
509            selector:@selector(menuDidClose:)
510                name:NSMenuDidEndTrackingNotification
511              object:menu];
512   }
513   return menu;
516 #pragma mark -
517 #pragma mark Testing Methods
519 - (void)setTestContextMenu:(NSMenu*)testContextMenu {
520   testContextMenu_ = testContextMenu;
523 - (BOOL)wantsToRunForTesting {
524   return viewController_->WantsToRun(
525       [browserActionsController_ currentWebContents]);
528 @end
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
545                 fromRect:NSZeroRect
546                operation:NSCompositeSourceOver
547                 fraction:1.0
548           respectFlipped:YES
549                    hints:nil];
552 - (void)drawFocusRingMaskWithFrame:(NSRect)cellFrame inView:(NSView*)view {
553   // Match the hover image's bezel.
554   [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(cellFrame, 2, 2)
555                                    xRadius:2
556                                    yRadius:2] fill];
559 - (ui::ThemeProvider*)themeProviderForWindow:(NSWindow*)window {
560   ui::ThemeProvider* themeProvider = [window themeProvider];
561   if (!themeProvider)
562     themeProvider =
563         [[browserActionsController_ browser]->window()->GetNativeWindow()
564             themeProvider];
565   return themeProvider;
568 @end