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.
4
5#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button.h"
6
7#include <cmath>
8
9#include "base/logging.h"
10#include "base/mac/foundation_util.h"
11#import "base/mac/scoped_nsobject.h"
12#import "chrome/browser/ui/cocoa/bookmarks/bookmark_bar_folder_window.h"
13#import "chrome/browser/ui/cocoa/bookmarks/bookmark_button_cell.h"
14#import "chrome/browser/ui/cocoa/browser_window_controller.h"
15#import "chrome/browser/ui/cocoa/view_id_util.h"
16#include "components/bookmarks/browser/bookmark_model.h"
17#include "content/public/browser/user_metrics.h"
18#import "ui/base/cocoa/nsview_additions.h"
19#include "ui/gfx/scoped_ns_graphics_context_save_gstate_mac.h"
20
21using base::UserMetricsAction;
22
23// The opacity of the bookmark button drag image.
24static const CGFloat kDragImageOpacity = 0.7;
25
26
27namespace bookmark_button {
28
29NSString* const kPulseBookmarkButtonNotification =
30    @"PulseBookmarkButtonNotification";
31NSString* const kBookmarkKey = @"BookmarkKey";
32NSString* const kBookmarkPulseFlagKey = @"BookmarkPulseFlagKey";
33
34};
35
36namespace {
37// We need a class variable to track the current dragged button to enable
38// proper live animated dragging behavior, and can't do it in the
39// delegate/controller since you can drag a button from one domain to the
40// other (from a "folder" menu, to the main bar, or vice versa).
41BookmarkButton* gDraggedButton = nil; // Weak
42};
43
44@interface BookmarkButton(Private)
45
46// Make a drag image for the button.
47- (NSImage*)dragImage;
48
49- (void)installCustomTrackingArea;
50
51@end  // @interface BookmarkButton(Private)
52
53
54@implementation BookmarkButton
55
56@synthesize delegate = delegate_;
57@synthesize acceptsTrackIn = acceptsTrackIn_;
58
59- (id)initWithFrame:(NSRect)frameRect {
60  // BookmarkButton's ViewID may be changed to VIEW_ID_OTHER_BOOKMARKS in
61  // BookmarkBarController, so we can't just override -viewID method to return
62  // it.
63  if ((self = [super initWithFrame:frameRect])) {
64    view_id_util::SetID(self, VIEW_ID_BOOKMARK_BAR_ELEMENT);
65    [self installCustomTrackingArea];
66  }
67  return self;
68}
69
70- (void)dealloc {
71  if ([[self cell] respondsToSelector:@selector(safelyStopPulsing)])
72    [[self cell] safelyStopPulsing];
73  view_id_util::UnsetID(self);
74
75  if (area_) {
76    [self removeTrackingArea:area_];
77    [area_ release];
78  }
79
80  [super dealloc];
81}
82
83- (const BookmarkNode*)bookmarkNode {
84  return [[self cell] bookmarkNode];
85}
86
87- (BOOL)isFolder {
88  const BookmarkNode* node = [self bookmarkNode];
89  return (node && node->is_folder());
90}
91
92- (BOOL)isEmpty {
93  return [self bookmarkNode] ? NO : YES;
94}
95
96- (void)setIsContinuousPulsing:(BOOL)flag {
97  [[self cell] setIsContinuousPulsing:flag];
98}
99
100- (BOOL)isContinuousPulsing {
101  return [[self cell] isContinuousPulsing];
102}
103
104- (NSPoint)screenLocationForRemoveAnimation {
105  NSPoint point;
106
107  if (dragPending_) {
108    // Use the position of the mouse in the drag image as the location.
109    point = dragEndScreenLocation_;
110    point.x += dragMouseOffset_.x;
111    if ([self isFlipped]) {
112      point.y += [self bounds].size.height - dragMouseOffset_.y;
113    } else {
114      point.y += dragMouseOffset_.y;
115    }
116  } else {
117    // Use the middle of this button as the location.
118    NSRect bounds = [self bounds];
119    point = NSMakePoint(NSMidX(bounds), NSMidY(bounds));
120    point = [self convertPoint:point toView:nil];
121    point = [[self window] convertBaseToScreen:point];
122  }
123
124  return point;
125}
126
127
128- (void)updateTrackingAreas {
129  [self installCustomTrackingArea];
130  [super updateTrackingAreas];
131}
132
133- (DraggableButtonResult)deltaIndicatesDragStartWithXDelta:(float)xDelta
134                                                    yDelta:(float)yDelta
135                                               xHysteresis:(float)xHysteresis
136                                               yHysteresis:(float)yHysteresis
137                                                 indicates:(BOOL*)result {
138  const float kDownProportion = 1.4142135f; // Square root of 2.
139
140  // We want to show a folder menu when you drag down on folder buttons,
141  // so don't classify this as a drag for that case.
142  if ([self isFolder] &&
143      (yDelta <= -yHysteresis) &&  // Bottom of hysteresis box was hit.
144      (std::abs(yDelta) / std::abs(xDelta)) >= kDownProportion) {
145    *result = NO;
146    return kDraggableButtonMixinDidWork;
147  }
148
149  return kDraggableButtonImplUseBase;
150}
151
152
153// By default, NSButton ignores middle-clicks.
154// But we want them.
155- (void)otherMouseUp:(NSEvent*)event {
156  [self performClick:self];
157}
158
159- (BOOL)acceptsTrackInFrom:(id)sender {
160  return  [self isFolder] || [self acceptsTrackIn];
161}
162
163
164// Overridden from DraggableButton.
165- (void)beginDrag:(NSEvent*)event {
166  // Don't allow a drag of the empty node.
167  // The empty node is a placeholder for "(empty)", to be revisited.
168  if ([self isEmpty])
169    return;
170
171  if (![self delegate]) {
172    NOTREACHED();
173    return;
174  }
175
176  if ([self isFolder]) {
177    // Close the folder's drop-down menu if it's visible.
178    [[self target] closeBookmarkFolder:self];
179  }
180
181  // At the moment, moving bookmarks causes their buttons (like me!)
182  // to be destroyed and rebuilt.  Make sure we don't go away while on
183  // the stack.
184  [self retain];
185
186  // Ask our delegate to fill the pasteboard for us.
187  NSPasteboard* pboard = [NSPasteboard pasteboardWithName:NSDragPboard];
188  [[self delegate] fillPasteboard:pboard forDragOfButton:self];
189
190  // Lock bar visibility, forcing the overlay to stay visible if we are in
191  // fullscreen mode.
192  if ([[self delegate] dragShouldLockBarVisibility]) {
193    DCHECK(!visibilityDelegate_);
194    NSWindow* window = [[self delegate] browserWindow];
195    visibilityDelegate_ =
196        [BrowserWindowController browserWindowControllerForWindow:window];
197    [visibilityDelegate_ lockBarVisibilityForOwner:self
198                                     withAnimation:NO
199                                             delay:NO];
200  }
201  const BookmarkNode* node = [self bookmarkNode];
202  const BookmarkNode* parent = node ? node->parent() : NULL;
203  if (parent && parent->type() == BookmarkNode::FOLDER) {
204    content::RecordAction(UserMetricsAction("BookmarkBarFolder_DragStart"));
205  } else {
206    content::RecordAction(UserMetricsAction("BookmarkBar_DragStart"));
207  }
208
209  dragMouseOffset_ = [self convertPoint:[event locationInWindow] fromView:nil];
210  dragPending_ = YES;
211  gDraggedButton = self;
212
213  CGFloat yAt = [self bounds].size.height;
214  NSSize dragOffset = NSMakeSize(0.0, 0.0);
215  NSImage* image = [self dragImage];
216  [self setHidden:YES];
217  [self dragImage:image at:NSMakePoint(0, yAt) offset:dragOffset
218            event:event pasteboard:pboard source:self slideBack:YES];
219  [self setHidden:NO];
220
221  // And we're done.
222  dragPending_ = NO;
223  gDraggedButton = nil;
224
225  [self autorelease];
226}
227
228// Overridden to release bar visibility.
229- (DraggableButtonResult)endDrag {
230  gDraggedButton = nil;
231
232  // visibilityDelegate_ can be nil if we're detached, and that's fine.
233  [visibilityDelegate_ releaseBarVisibilityForOwner:self
234                                      withAnimation:YES
235                                              delay:YES];
236  visibilityDelegate_ = nil;
237
238  return kDraggableButtonImplUseBase;
239}
240
241- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)isLocal {
242  NSDragOperation operation = NSDragOperationCopy;
243  if (isLocal) {
244    operation |= NSDragOperationMove;
245  }
246  if ([delegate_ canDragBookmarkButtonToTrash:self]) {
247    operation |= NSDragOperationDelete;
248  }
249  return operation;
250}
251
252- (void)draggedImage:(NSImage *)anImage
253             endedAt:(NSPoint)aPoint
254           operation:(NSDragOperation)operation {
255  gDraggedButton = nil;
256  // Inform delegate of drag source that we're finished dragging,
257  // so it can close auto-opened bookmark folders etc.
258  [delegate_ bookmarkDragDidEnd:self
259                      operation:operation];
260  // Tell delegate if it should delete us.
261  if (operation & NSDragOperationDelete) {
262    dragEndScreenLocation_ = aPoint;
263    [delegate_ didDragBookmarkToTrash:self];
264  }
265}
266
267- (DraggableButtonResult)performMouseDownAction:(NSEvent*)theEvent {
268  int eventMask = NSLeftMouseUpMask | NSMouseEnteredMask | NSMouseExitedMask |
269      NSLeftMouseDraggedMask;
270
271  BOOL keepGoing = YES;
272  [[self target] performSelector:[self action] withObject:self];
273  self.draggableButton.actionHasFired = YES;
274
275  DraggableButton* insideBtn = nil;
276
277  while (keepGoing) {
278    theEvent = [[self window] nextEventMatchingMask:eventMask];
279    if (!theEvent)
280      continue;
281
282    NSPoint mouseLoc = [self convertPoint:[theEvent locationInWindow]
283                                 fromView:nil];
284    BOOL isInside = [self mouse:mouseLoc inRect:[self bounds]];
285
286    switch ([theEvent type]) {
287      case NSMouseEntered:
288      case NSMouseExited: {
289        NSView* trackedView = (NSView*)[[theEvent trackingArea] owner];
290        if (trackedView && [trackedView isKindOfClass:[self class]]) {
291          BookmarkButton* btn = static_cast<BookmarkButton*>(trackedView);
292          if (![btn acceptsTrackInFrom:self])
293            break;
294          if ([theEvent type] == NSMouseEntered) {
295            [[NSCursor arrowCursor] set];
296            [[btn cell] mouseEntered:theEvent];
297            insideBtn = btn;
298          } else {
299            [[btn cell] mouseExited:theEvent];
300            if (insideBtn == btn)
301              insideBtn = nil;
302          }
303        }
304        break;
305      }
306      case NSLeftMouseDragged: {
307        if (insideBtn)
308          [insideBtn mouseDragged:theEvent];
309        break;
310      }
311      case NSLeftMouseUp: {
312        self.draggableButton.durationMouseWasDown =
313            [theEvent timestamp] - self.draggableButton.whenMouseDown;
314        if (!isInside && insideBtn && insideBtn != self) {
315          // Has tracked onto another BookmarkButton menu item, and released,
316          // so fire its action.
317          [[insideBtn target] performSelector:[insideBtn action]
318                                   withObject:insideBtn];
319
320        } else {
321          [self secondaryMouseUpAction:isInside];
322          [[self cell] mouseExited:theEvent];
323          [[insideBtn cell] mouseExited:theEvent];
324        }
325        keepGoing = NO;
326        break;
327      }
328      default:
329        /* Ignore any other kind of event. */
330        break;
331    }
332  }
333  return kDraggableButtonMixinDidWork;
334}
335
336
337
338// mouseEntered: and mouseExited: are called from our
339// BookmarkButtonCell.  We redirect this information to our delegate.
340// The controller can then perform menu-like actions (e.g. "hover over
341// to open menu").
342- (void)mouseEntered:(NSEvent*)event {
343  [delegate_ mouseEnteredButton:self event:event];
344}
345
346// See comments above mouseEntered:.
347- (void)mouseExited:(NSEvent*)event {
348  [delegate_ mouseExitedButton:self event:event];
349}
350
351- (void)mouseMoved:(NSEvent*)theEvent {
352  if ([delegate_ respondsToSelector:@selector(mouseMoved:)])
353    [id(delegate_) mouseMoved:theEvent];
354}
355
356- (void)mouseDragged:(NSEvent*)theEvent {
357  if ([delegate_ respondsToSelector:@selector(mouseDragged:)])
358    [id(delegate_) mouseDragged:theEvent];
359}
360
361- (void)rightMouseDown:(NSEvent*)event {
362  // Ensure that right-clicking on a button while a context menu is open
363  // highlights the new button.
364  GradientButtonCell* cell =
365      base::mac::ObjCCastStrict<GradientButtonCell>([self cell]);
366  [delegate_ mouseEnteredButton:self event:event];
367  [cell setMouseInside:YES animate:YES];
368
369  // Keep a ref to |self|, in case -rightMouseDown: deletes this bookmark.
370  base::scoped_nsobject<BookmarkButton> keepAlive([self retain]);
371  [super rightMouseDown:event];
372
373  if (![cell isMouseReallyInside]) {
374    [cell setMouseInside:NO animate:YES];
375    [delegate_ mouseExitedButton:self event:event];
376  }
377}
378
379+ (BookmarkButton*)draggedButton {
380  return gDraggedButton;
381}
382
383- (BOOL)canBecomeKeyView {
384  if (![super canBecomeKeyView])
385    return NO;
386
387  // If button is an item in a folder menu, don't become key.
388  return ![[self cell] isFolderButtonCell];
389}
390
391// This only gets called after a click that wasn't a drag, and only on folders.
392- (DraggableButtonResult)secondaryMouseUpAction:(BOOL)wasInside {
393  const NSTimeInterval kShortClickLength = 0.5;
394  // Long clicks that end over the folder button result in the menu hiding.
395  if (wasInside &&
396      self.draggableButton.durationMouseWasDown > kShortClickLength) {
397    [[self target] performSelector:[self action] withObject:self];
398  } else {
399    // Mouse tracked out of button during menu track. Hide menus.
400    if (!wasInside)
401      [delegate_ bookmarkDragDidEnd:self
402                          operation:NSDragOperationNone];
403  }
404  return kDraggableButtonMixinDidWork;
405}
406
407- (BOOL)isOpaque {
408  // Make this control opaque so that sub-pixel anti-aliasing works when
409  // CoreAnimation is enabled.
410  return YES;
411}
412
413- (void)drawRect:(NSRect)rect {
414  NSView* bookmarkBarToolbarView = [[self superview] superview];
415  [self cr_drawUsingAncestor:bookmarkBarToolbarView inRect:(NSRect)rect];
416  [super drawRect:rect];
417}
418
419@end
420
421@implementation BookmarkButton(Private)
422
423
424- (void)installCustomTrackingArea {
425  const NSTrackingAreaOptions options =
426      NSTrackingActiveAlways |
427      NSTrackingMouseEnteredAndExited |
428      NSTrackingEnabledDuringMouseDrag;
429
430  if (area_) {
431    [self removeTrackingArea:area_];
432    [area_ release];
433  }
434
435  area_ = [[NSTrackingArea alloc] initWithRect:[self bounds]
436                                       options:options
437                                         owner:self
438                                      userInfo:nil];
439  [self addTrackingArea:area_];
440}
441
442
443- (NSImage*)dragImage {
444  NSRect bounds = [self bounds];
445  base::scoped_nsobject<NSImage> image(
446      [[NSImage alloc] initWithSize:bounds.size]);
447  [image lockFocusFlipped:[self isFlipped]];
448
449  NSGraphicsContext* context = [NSGraphicsContext currentContext];
450  CGContextRef cgContext = static_cast<CGContextRef>([context graphicsPort]);
451  CGContextBeginTransparencyLayer(cgContext, 0);
452  CGContextSetAlpha(cgContext, kDragImageOpacity);
453
454  GradientButtonCell* cell =
455      base::mac::ObjCCastStrict<GradientButtonCell>([self cell]);
456  [[cell clipPathForFrame:bounds inView:self] setClip];
457  [cell drawWithFrame:bounds inView:self];
458
459  CGContextEndTransparencyLayer(cgContext);
460  [image unlockFocus];
461
462  return image.autorelease();
463}
464
465@end  // @implementation BookmarkButton(Private)
466