blob: 7cc9c454091eddda15a5daf3946a31fc726c4aec [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "chrome/browser/ui/cocoa/extensions/browser_action_button.h"
#include <algorithm>
#include <cmath>
#include "base/logging.h"
#include "base/mac/foundation_util.h"
#include "base/macros.h"
#include "base/memory/weak_ptr.h"
#include "base/strings/sys_string_conversions.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_window.h"
#import "chrome/browser/ui/cocoa/app_menu/app_menu_controller.h"
#import "chrome/browser/ui/cocoa/browser_window_controller.h"
#import "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h"
#import "chrome/browser/ui/cocoa/themed_window.h"
#import "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h"
#include "chrome/browser/ui/toolbar/toolbar_action_view_controller.h"
#include "chrome/browser/ui/toolbar/toolbar_action_view_delegate.h"
#include "chrome/browser/ui/toolbar/toolbar_actions_bar.h"
#include "skia/ext/skia_utils_mac.h"
#import "third_party/google_toolbox_for_mac/src/AppKit/GTMNSAnimation+Duration.h"
#import "ui/base/cocoa/menu_controller.h"
#include "ui/base/material_design/material_design_controller.h"
#include "ui/gfx/canvas_skia_paint.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/image/image.h"
#include "ui/gfx/scoped_ns_graphics_context_save_gstate_mac.h"
NSString* const kBrowserActionButtonDraggingNotification =
@"BrowserActionButtonDraggingNotification";
NSString* const kBrowserActionButtonDragEndNotification =
@"BrowserActionButtonDragEndNotification";
static const CGFloat kAnimationDuration = 0.2;
static const CGFloat kMinimumDragDistance = 5;
@interface BrowserActionButton ()
- (void)endDrag;
- (void)updateHighlightedState;
- (MenuController*)contextMenuController;
- (void)menuDidClose:(NSNotification*)notification;
@end
// A class to bridge the ToolbarActionViewController and the
// BrowserActionButton.
class ToolbarActionViewDelegateBridge : public ToolbarActionViewDelegate {
public:
ToolbarActionViewDelegateBridge(BrowserActionButton* owner,
BrowserActionsController* controller,
ToolbarActionViewController* viewController);
~ToolbarActionViewDelegateBridge() override;
// Shows the context menu for the owning action.
void ShowContextMenu();
bool user_shown_popup_visible() const { return user_shown_popup_visible_; }
private:
// ToolbarActionViewDelegate:
content::WebContents* GetCurrentWebContents() const override;
void UpdateState() override;
bool IsMenuRunning() const override;
void OnPopupShown(bool by_user) override;
void OnPopupClosed() override;
// A helper method to implement showing the context menu.
void DoShowContextMenu();
// The owning button. Weak.
BrowserActionButton* owner_;
// The BrowserActionsController that owns the button. Weak.
BrowserActionsController* controller_;
// The ToolbarActionViewController for which this is the delegate. Weak.
ToolbarActionViewController* viewController_;
// Whether or not a popup is visible from a user action.
bool user_shown_popup_visible_;
// Whether or not a context menu is running (or is in the process of opening).
bool contextMenuRunning_;
base::WeakPtrFactory<ToolbarActionViewDelegateBridge> weakFactory_;
DISALLOW_COPY_AND_ASSIGN(ToolbarActionViewDelegateBridge);
};
ToolbarActionViewDelegateBridge::ToolbarActionViewDelegateBridge(
BrowserActionButton* owner,
BrowserActionsController* controller,
ToolbarActionViewController* viewController)
: owner_(owner),
controller_(controller),
viewController_(viewController),
user_shown_popup_visible_(false),
contextMenuRunning_(false),
weakFactory_(this) {
viewController_->SetDelegate(this);
}
ToolbarActionViewDelegateBridge::~ToolbarActionViewDelegateBridge() {
viewController_->SetDelegate(nullptr);
}
void ToolbarActionViewDelegateBridge::ShowContextMenu() {
DCHECK(![controller_ toolbarActionsBar]->in_overflow_mode());
if ([owner_ superview]) {
// If the button is already visible on the toolbar, we can skip ahead to
// just showing the menu.
DoShowContextMenu();
return;
}
// Otherwise, we have to slide the button out.
contextMenuRunning_ = true;
AppMenuController* appMenuController =
[[[BrowserWindowController browserWindowControllerForWindow:
[controller_ browser]->window()->GetNativeWindow()]
toolbarController] appMenuController];
// If the app menu is open, we have to first close it. Part of this happens
// asynchronously, so we have to use a posted task to open the next menu.
if ([appMenuController isMenuOpen])
[appMenuController cancel];
[controller_ toolbarActionsBar]->PopOutAction(
viewController_,
false,
base::Bind(&ToolbarActionViewDelegateBridge::DoShowContextMenu,
weakFactory_.GetWeakPtr()));
}
content::WebContents* ToolbarActionViewDelegateBridge::GetCurrentWebContents()
const {
return [controller_ currentWebContents];
}
void ToolbarActionViewDelegateBridge::UpdateState() {
[owner_ updateState];
}
bool ToolbarActionViewDelegateBridge::IsMenuRunning() const {
MenuController* menuController = [owner_ contextMenuController];
return contextMenuRunning_ || (menuController && [menuController isMenuOpen]);
}
void ToolbarActionViewDelegateBridge::OnPopupShown(bool by_user) {
if (by_user)
user_shown_popup_visible_ = true;
[owner_ updateHighlightedState];
}
void ToolbarActionViewDelegateBridge::OnPopupClosed() {
user_shown_popup_visible_ = false;
[owner_ updateHighlightedState];
}
void ToolbarActionViewDelegateBridge::DoShowContextMenu() {
// The point the menu shows matches that of the normal app menu - that is, the
// right-left most corner of the menu is left-aligned with the app button,
// and the menu is displayed "a little bit" lower. It would be nice to be able
// to avoid the magic '5' here, but since it's built into Cocoa, it's not too
// hopeful.
NSPoint menuPoint = NSMakePoint(0, NSHeight([owner_ bounds]) + 5);
base::WeakPtr<ToolbarActionViewDelegateBridge> weakThis =
weakFactory_.GetWeakPtr();
[[owner_ cell] setHighlighted:YES];
[[owner_ menu] popUpMenuPositioningItem:nil
atLocation:menuPoint
inView:owner_];
// Since menus run in a blocking way, it's possible that the extension was
// unloaded since this point.
if (!weakThis)
return;
[[owner_ cell] setHighlighted:NO];
contextMenuRunning_ = false;
}
@implementation BrowserActionButton
@synthesize isBeingDragged = isBeingDragged_;
+ (Class)cellClass {
return [BrowserActionCell class];
}
- (id)initWithFrame:(NSRect)frame
viewController:(ToolbarActionViewController*)viewController
controller:(BrowserActionsController*)controller {
if ((self = [super initWithFrame:frame])) {
BrowserActionCell* cell = [[[BrowserActionCell alloc] init] autorelease];
// [NSButton setCell:] warns to NOT use setCell: other than in the
// initializer of a control. However, we are using a basic
// NSButton whose initializer does not take an NSCell as an
// object. To honor the assumed semantics, we do nothing with
// NSButton between alloc/init and setCell:.
[self setCell:cell];
browserActionsController_ = controller;
viewController_ = viewController;
viewControllerDelegate_.reset(
new ToolbarActionViewDelegateBridge(self, controller, viewController));
[cell setBrowserActionsController:controller];
[cell
accessibilitySetOverrideValue:base::SysUTF16ToNSString(
viewController_->GetAccessibleName([controller currentWebContents]))
forAttribute:NSAccessibilityDescriptionAttribute];
[self setTitle:@""];
[self setButtonType:NSMomentaryChangeButton];
[self setShowsBorderOnlyWhileMouseInside:YES];
moveAnimation_.reset([[NSViewAnimation alloc] init]);
[moveAnimation_ gtm_setDuration:kAnimationDuration
eventMask:NSLeftMouseUpMask];
[moveAnimation_ setAnimationBlockingMode:NSAnimationNonblocking];
[self updateState];
}
return self;
}
- (BOOL)acceptsFirstResponder {
return YES;
}
- (void)rightMouseDown:(NSEvent*)theEvent {
// Cocoa doesn't allow menus-running-in-menus, so in order to show the
// context menu for an overflowed action, we close the app menu and show the
// context menu over the app menu (similar to what we do for popups).
// Let the main bar's button handle showing the context menu, since the app
// menu will close..
if ([browserActionsController_ isOverflow]) {
[browserActionsController_ mainButtonForId:viewController_->GetId()]->
viewControllerDelegate_->ShowContextMenu();
} else {
[super rightMouseDown:theEvent];
}
}
- (void)mouseDown:(NSEvent*)theEvent {
NSPoint location = [self convertPoint:[theEvent locationInWindow]
fromView:nil];
// We don't allow dragging in the overflow container because mouse events
// don't work well in menus in Cocoa. Specifically, the minute the mouse
// leaves the view, the view stops receiving events. This is bad, because the
// mouse can leave the view in many ways (user moves the mouse fast, user
// tries to drag the icon to a non-applicable place, like outside the menu,
// etc). When the mouse leaves, we get no indication (no mouseUp), so we can't
// even handle that case - and are left in the middle of a drag. Instead, we
// have to simply disable dragging.
//
// NOTE(devlin): If we use a greedy event loop that consumes all incoming
// events (i.e. using [NSWindow nextEventMatchingMask]), we can make this
// work. The downside to that is that all other events are lost. Disable this
// for now, and revisit it at a later date.
if (NSPointInRect(location, [self bounds]) &&
![browserActionsController_ isOverflow]) {
dragCouldStart_ = YES;
dragStartPoint_ = [self convertPoint:[theEvent locationInWindow]
fromView:nil];
[self updateHighlightedState];
}
}
- (void)mouseDragged:(NSEvent*)theEvent {
if (!dragCouldStart_)
return;
NSPoint eventPoint = [theEvent locationInWindow];
if (!isBeingDragged_) {
// Don't initiate a drag until it moves at least kMinimumDragDistance.
NSPoint dragStart = [self convertPoint:dragStartPoint_ toView:nil];
CGFloat dx = eventPoint.x - dragStart.x;
CGFloat dy = eventPoint.y - dragStart.y;
if (dx*dx + dy*dy < kMinimumDragDistance*kMinimumDragDistance)
return;
// The start of a drag. Position the button above all others.
[[self superview] addSubview:self positioned:NSWindowAbove relativeTo:nil];
// We reset the |dragStartPoint_| so that the mouse can always be in the
// same point along the button's x axis, and we avoid a "jump" when first
// starting to drag.
dragStartPoint_ = [self convertPoint:eventPoint fromView:nil];
isBeingDragged_ = YES;
}
NSRect buttonFrame = [self frame];
// The desired x is the current mouse point, minus the original offset of the
// mouse into the button.
NSPoint localPoint = [[self superview] convertPoint:eventPoint fromView:nil];
CGFloat desiredX = localPoint.x - dragStartPoint_.x;
// Clamp the button to be within its superview along the X-axis.
NSRect containerBounds = [[self superview] bounds];
desiredX = std::min(std::max(NSMinX(containerBounds), desiredX),
NSMaxX(containerBounds) - NSWidth(buttonFrame));
buttonFrame.origin.x = desiredX;
// If the button is in the overflow menu, it could move along the y-axis, too.
if ([browserActionsController_ isOverflow]) {
CGFloat desiredY = localPoint.y - dragStartPoint_.y;
desiredY = std::min(std::max(NSMinY(containerBounds), desiredY),
NSMaxY(containerBounds) - NSHeight(buttonFrame));
buttonFrame.origin.y = desiredY;
}
[self setFrame:buttonFrame];
[self setNeedsDisplay:YES];
[[NSNotificationCenter defaultCenter]
postNotificationName:kBrowserActionButtonDraggingNotification
object:self];
}
- (void)mouseUp:(NSEvent*)theEvent {
dragCouldStart_ = NO;
// There are non-drag cases where a mouseUp: may happen
// (e.g. mouse-down, cmd-tab to another application, move mouse,
// mouse-up).
NSPoint location = [self convertPoint:[theEvent locationInWindow]
fromView:nil];
// Only perform the click if we didn't drag the button.
if (NSPointInRect(location, [self bounds]) && !isBeingDragged_) {
[self performClick:self];
} else {
// Make sure an ESC to end a drag doesn't trigger 2 endDrags.
if (isBeingDragged_) {
[self endDrag];
} else {
[super mouseUp:theEvent];
}
}
[self updateHighlightedState];
}
- (void)endDrag {
isBeingDragged_ = NO;
[[NSNotificationCenter defaultCenter]
postNotificationName:kBrowserActionButtonDragEndNotification object:self];
}
- (void)updateHighlightedState {
// The button's cell is highlighted if either the popup is showing by a user
// action, or the user is about to drag the button, unless the button is
// overflowed (in which case it is never highlighted).
if ([self superview] && ![browserActionsController_ isOverflow]) {
BOOL highlighted = viewControllerDelegate_->user_shown_popup_visible() ||
dragCouldStart_;
[[self cell] setHighlighted:highlighted];
} else {
[[self cell] setHighlighted:NO];
}
}
- (MenuController*)contextMenuController {
return contextMenuController_.get();
}
- (void)menuDidClose:(NSNotification*)notification {
viewController_->OnContextMenuClosed();
}
- (void)setFrame:(NSRect)frameRect animate:(BOOL)animate {
if (!animate) {
[self setFrame:frameRect];
} else {
if ([moveAnimation_ isAnimating])
[moveAnimation_ stopAnimation];
NSDictionary* animationDictionary = @{
NSViewAnimationTargetKey : self,
NSViewAnimationStartFrameKey : [NSValue valueWithRect:[self frame]],
NSViewAnimationEndFrameKey : [NSValue valueWithRect:frameRect]
};
[moveAnimation_ setViewAnimations: @[ animationDictionary ]];
[moveAnimation_ startAnimation];
}
}
- (void)updateState {
content::WebContents* webContents =
[browserActionsController_ currentWebContents];
if (!webContents)
return;
base::string16 tooltip = viewController_->GetTooltip(webContents);
[self setToolTip:(tooltip.empty() ? nil : base::SysUTF16ToNSString(tooltip))];
gfx::Image image =
viewController_->GetIcon(webContents, gfx::Size([self frame].size));
if (!image.IsEmpty())
[self setImage:image.ToNSImage()];
BOOL enabled = viewController_->IsEnabled(webContents) ||
viewController_->DisabledClickOpensMenu();
[self setEnabled:enabled];
[self setNeedsDisplay:YES];
}
- (void)onRemoved {
// The button is being removed from the toolbar, and the backing controller
// will also be removed. Destroy the delegate.
// We only need to do this because in Cocoa's memory management, removing the
// button from the toolbar doesn't synchronously dealloc it.
viewControllerDelegate_.reset();
// Also reset the context menu, since it has a dependency on the backing
// controller (which owns its model).
contextMenuController_.reset();
// Remove any lingering observations.
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (BOOL)isAnimating {
return [moveAnimation_ isAnimating];
}
- (void)stopAnimation {
// Stopping an in-progress NSViewAnimation sets the view's frame to the end
// frame of the animation. We want animation to stop in-place, so re-set the
// frame to what it is currently.
NSRect frame = [self frame];
if ([moveAnimation_ isAnimating])
[moveAnimation_ stopAnimation];
[self setFrame:frame];
}
- (NSRect)frameAfterAnimation {
if ([moveAnimation_ isAnimating]) {
NSRect endFrame = [[[[moveAnimation_ viewAnimations] objectAtIndex:0]
valueForKey:NSViewAnimationEndFrameKey] rectValue];
return endFrame;
} else {
return [self frame];
}
}
- (ToolbarActionViewController*)viewController {
return viewController_;
}
- (NSImage*)compositedImage {
NSRect bounds = [self bounds];
NSImage* image = [[[NSImage alloc] initWithSize:bounds.size] autorelease];
[image lockFocus];
[[NSColor clearColor] set];
NSRectFill(bounds);
NSImage* actionImage = [self image];
const NSSize imageSize = [actionImage size];
const NSRect imageRect =
NSMakeRect(std::floor((NSWidth(bounds) - imageSize.width) / 2.0),
std::floor((NSHeight(bounds) - imageSize.height) / 2.0),
imageSize.width, imageSize.height);
[actionImage drawInRect:imageRect
fromRect:NSZeroRect
operation:NSCompositeSourceOver
fraction:1.0
respectFlipped:YES
hints:nil];
[image unlockFocus];
return image;
}
- (void)showContextMenu {
viewControllerDelegate_->ShowContextMenu();
}
- (NSMenu*)menu {
// Hack: Since Cocoa doesn't support menus-running-in-menus (see also comment
// in -rightMouseDown:), it doesn't launch the menu for an overflowed action
// on a Control-click. Even more unfortunate, it doesn't even pass us the
// mouseDown event for control clicks. However, it does call -menuForEvent:,
// which in turn calls -menu:, so we can tap in here and show the menu
// programmatically for the Control-click case.
if ([browserActionsController_ isOverflow] &&
([NSEvent modifierFlags] & NSControlKeyMask)) {
[browserActionsController_ mainButtonForId:viewController_->GetId()]->
viewControllerDelegate_->ShowContextMenu();
return nil;
}
NSMenu* menu = nil;
if (testContextMenu_) {
menu = testContextMenu_;
} else {
// Make sure we delete any references to an old menu.
contextMenuController_.reset();
ui::MenuModel* contextMenu = viewController_->GetContextMenu();
if (contextMenu) {
contextMenuController_.reset(
[[MenuController alloc] initWithModel:contextMenu
useWithPopUpButtonCell:NO]);
menu = [contextMenuController_ menu];
}
}
if (menu) {
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(menuDidClose:)
name:NSMenuDidEndTrackingNotification
object:menu];
}
return menu;
}
#pragma mark -
#pragma mark Testing Methods
- (void)setTestContextMenu:(NSMenu*)testContextMenu {
testContextMenu_ = testContextMenu;
}
- (BOOL)wantsToRunForTesting {
return viewController_->WantsToRun(
[browserActionsController_ currentWebContents]);
}
- (BOOL)isHighlighted {
return [[self cell] isHighlighted];
}
@end
@implementation BrowserActionCell
@synthesize browserActionsController = browserActionsController_;
- (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView {
gfx::ScopedNSGraphicsContextSaveGState scopedGState;
[super drawWithFrame:cellFrame inView:controlView];
const NSSize imageSize = self.image.size;
const NSRect imageRect =
NSMakeRect(std::floor((NSWidth(cellFrame) - imageSize.width) / 2.0),
std::floor((NSHeight(cellFrame) - imageSize.height) / 2.0),
imageSize.width, imageSize.height);
[self.image drawInRect:imageRect
fromRect:NSZeroRect
operation:NSCompositeSourceOver
fraction:1.0
respectFlipped:YES
hints:nil];
}
- (void)drawFocusRingMaskWithFrame:(NSRect)cellFrame inView:(NSView*)view {
// Match the hover image's bezel.
[[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(cellFrame, 2, 2)
xRadius:2
yRadius:2] fill];
}
- (const ui::ThemeProvider*)themeProviderForWindow:(NSWindow*)window {
const ui::ThemeProvider* themeProvider = [window themeProvider];
if (!themeProvider)
themeProvider =
[[browserActionsController_ browser]->window()->GetNativeWindow()
themeProvider];
return themeProvider;
}
@end