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/download/download_shelf_controller.h"
7 #include "base/mac/bundle_locations.h"
8 #include "base/strings/sys_string_conversions.h"
9 #include "chrome/browser/download/download_stats.h"
10 #include "chrome/browser/profiles/profile.h"
11 #include "chrome/browser/themes/theme_service.h"
12 #include "chrome/browser/themes/theme_service_factory.h"
13 #include "chrome/browser/ui/browser.h"
14 #include "chrome/browser/ui/chrome_pages.h"
15 #import "chrome/browser/ui/cocoa/animatable_view.h"
16 #include "chrome/browser/ui/cocoa/browser_window_cocoa.h"
17 #import "chrome/browser/ui/cocoa/browser_window_controller.h"
18 #include "chrome/browser/ui/cocoa/download/download_item_controller.h"
19 #include "chrome/browser/ui/cocoa/download/download_shelf_mac.h"
20 #import "chrome/browser/ui/cocoa/download/download_shelf_view_cocoa.h"
21 #import "chrome/browser/ui/cocoa/presentation_mode_controller.h"
22 #include "content/public/browser/download_item.h"
23 #include "content/public/browser/download_manager.h"
24 #import "third_party/google_toolbox_for_mac/src/AppKit/GTMNSAnimation+Duration.h"
25 #import "ui/base/cocoa/hover_button.h"
27 using content::DownloadItem;
29 // Download shelf autoclose behavior:
31 // The download shelf autocloses if all of this is true:
32 // 1) An item on the shelf has just been opened or removed.
33 // 2) All remaining items on the shelf have been opened in the past.
34 // 3) The mouse leaves the shelf and remains off the shelf for 5 seconds.
36 // If the mouse re-enters the shelf within the 5 second grace period, the
37 // autoclose is canceled. An autoclose can only be scheduled in response to a
38 // shelf item being opened or removed. If an item is opened and then the
39 // resulting autoclose is canceled, subsequent mouse exited events will NOT
40 // trigger an autoclose.
42 // If the shelf is manually closed while a download is still in progress, that
43 // download is marked as "opened" for these purposes. If the shelf is later
44 // reopened, these previously-in-progress download will not block autoclose,
45 // even if that download was never actually clicked on and opened.
49 // Max number of download views we'll contain. Any time a view is added and
50 // we already have this many download views, one is removed.
51 const size_t kMaxDownloadItemCount = 16;
53 // Horizontal padding between two download items.
54 const int kDownloadItemPadding = 0;
56 // Duration for the open-new-leftmost-item animation, in seconds.
57 const NSTimeInterval kDownloadItemOpenDuration = 0.8;
59 // Duration for download shelf closing animation, in seconds.
60 const NSTimeInterval kDownloadShelfCloseDuration = 0.12;
62 // Amount of time between when the mouse is moved off the shelf and the shelf is
63 // autoclosed, in seconds.
64 const NSTimeInterval kAutoCloseDelaySeconds = 5;
66 // The size of the x button by default.
67 const NSSize kHoverCloseButtonDefaultSize = { 18, 18 };
71 @interface DownloadShelfController(Private)
72 - (void)removeDownload:(DownloadItemController*)download
73 isShelfClosing:(BOOL)isShelfClosing;
74 - (void)layoutItems:(BOOL)skipFirst;
76 - (void)maybeAutoCloseAfterDelay;
77 - (void)scheduleAutoClose;
78 - (void)cancelAutoClose;
80 - (void)viewFrameDidChange:(NSNotification*)notification;
81 - (void)installTrackingArea;
82 - (void)removeTrackingArea;
83 - (void)willEnterFullscreen;
84 - (void)willLeaveFullscreen;
85 - (void)updateCloseButton;
89 @implementation DownloadShelfController
91 - (id)initWithBrowser:(Browser*)browser
92 resizeDelegate:(id<ViewResizer>)resizeDelegate {
93 if ((self = [super initWithNibName:@"DownloadShelf"
94 bundle:base::mac::FrameworkBundle()])) {
95 resizeDelegate_ = resizeDelegate;
96 maxShelfHeight_ = NSHeight([[self view] bounds]);
97 currentShelfHeight_ = maxShelfHeight_;
98 if (browser && browser->window())
99 isFullscreen_ = browser->window()->IsFullscreen();
103 // Reset the download shelf's frame height to zero. It will be properly
104 // positioned and sized the first time we try to set its height. (Just
105 // setting the rect to NSZeroRect does not work: it confuses Cocoa's view
106 // layout logic. If the shelf's width is too small, cocoa makes the download
107 // item container view wider than the browser window).
108 NSRect frame = [[self view] frame];
109 frame.size.height = 0;
110 [[self view] setFrame:frame];
112 downloadItemControllers_.reset([[NSMutableArray alloc] init]);
114 bridge_.reset(new DownloadShelfMac(browser, self));
115 navigator_ = browser;
120 - (void)awakeFromNib {
121 DCHECK(hoverCloseButton_);
123 NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
124 [[self animatableView] setResizeDelegate:resizeDelegate_];
125 [[self view] setPostsFrameChangedNotifications:YES];
126 [defaultCenter addObserver:self
127 selector:@selector(viewFrameDidChange:)
128 name:NSViewFrameDidChangeNotification
131 // These notifications are declared in fullscreen_controller, and are posted
133 [defaultCenter addObserver:self
134 selector:@selector(willEnterFullscreen)
135 name:kWillEnterFullscreenNotification
137 [defaultCenter addObserver:self
138 selector:@selector(willLeaveFullscreen)
139 name:kWillLeaveFullscreenNotification
141 [self installTrackingArea];
145 [self browserWillBeDestroyed];
149 - (void)browserWillBeDestroyed {
150 [[NSNotificationCenter defaultCenter] removeObserver:self];
152 // We need to explicitly release our download controllers here since they need
153 // to remove themselves as observers before the remaining shutdown happens.
154 [[self animatableView] stopAnimation];
155 [self removeTrackingArea];
156 [self cancelAutoClose];
157 while ([downloadItemControllers_ count] > 0) {
158 [self removeDownload:[downloadItemControllers_ lastObject]
161 downloadItemControllers_.reset();
164 navigator_ = nullptr;
167 // Called after the frame's rect has changed; usually when the height is
169 - (void)viewFrameDidChange:(NSNotification*)notification {
170 // Anchor subviews at the top of |view|, so that it looks like the shelf
172 CGFloat newShelfHeight = NSHeight([[self view] frame]);
173 if (newShelfHeight == currentShelfHeight_)
176 for (NSView* view in [[self view] subviews]) {
177 NSRect frame = [view frame];
178 frame.origin.y -= currentShelfHeight_ - newShelfHeight;
179 [view setFrame:frame];
181 currentShelfHeight_ = newShelfHeight;
184 - (AnimatableView*)animatableView {
185 return static_cast<AnimatableView*>([self view]);
188 - (IBAction)showDownloadsTab:(id)sender {
189 chrome::ShowDownloads(bridge_->browser());
192 - (IBAction)handleClose:(id)sender {
193 bridge_->Close(DownloadShelf::USER_ACTION);
196 - (void)remove:(DownloadItemController*)download {
197 [self removeDownload:download
201 - (void)removeDownload:(DownloadItemController*)download
202 isShelfClosing:(BOOL)isShelfClosing {
203 // Look for the download in our controller array and remove it. This will
204 // explicity release it so that it removes itself as an Observer of the
205 // DownloadItem. We don't want to wait for autorelease since the DownloadItem
206 // we are observing will likely be gone by then.
207 [[NSNotificationCenter defaultCenter] removeObserver:download];
209 // TODO(dmaclach): Remove -- http://crbug.com/25845
210 [[download view] removeFromSuperview];
212 [downloadItemControllers_ removeObject:download];
214 if (!isShelfClosing) {
217 // If there are no more downloads or if all the remaining downloads have
218 // been opened, we can close the shelf.
219 [self maybeAutoCloseAfterDelay];
223 - (void)downloadWasOpened:(DownloadItemController*)item_controller {
224 // This should only be called on the main thead.
225 DCHECK([NSThread isMainThread]);
226 [self maybeAutoCloseAfterDelay];
229 - (void)showDownloadShelf:(BOOL)show
230 isUserAction:(BOOL)isUserAction {
231 [self cancelAutoClose];
232 shouldCloseOnMouseExit_ = NO;
234 if ([self isVisible] == show)
238 int numInProgress = 0;
239 for (NSUInteger i = 0; i < [downloadItemControllers_ count]; ++i) {
240 DownloadItem* item = [[downloadItemControllers_ objectAtIndex:i]download];
241 if (item->GetState() == DownloadItem::IN_PROGRESS)
244 RecordDownloadShelfClose(
245 [downloadItemControllers_ count], numInProgress, !isUserAction);
248 // Animate the shelf out, but not in.
249 // TODO(rohitrao): We do not animate on the way in because Cocoa is already
250 // doing a lot of work to set up the download arrow animation. I've chosen to
251 // do no animation over janky animation. Find a way to make animating in
253 AnimatableView* view = [self animatableView];
255 [view setHeight:maxShelfHeight_];
258 [view animateToNewHeight:0 duration:kDownloadShelfCloseDuration];
261 barIsVisible_ = show;
262 [self updateCloseButton];
265 - (DownloadShelf*)bridge {
266 return bridge_.get();
270 return barIsVisible_;
273 - (void)animationDidEnd:(NSAnimation*)animation {
274 if (![self isVisible]) {
276 [[self view] setHidden:YES]; // So that it doesn't appear in AX hierarchy.
281 return maxShelfHeight_;
284 // If |skipFirst| is true, the frame of the leftmost item is not set.
285 - (void)layoutItems:(BOOL)skipFirst {
286 CGFloat currentX = 0;
287 for (DownloadItemController* itemController
288 in downloadItemControllers_.get()) {
289 NSRect frame = [[itemController view] frame];
290 frame.origin.x = currentX;
291 frame.size.width = [itemController preferredSize].width;
293 [[[itemController view] animator] setFrame:frame];
294 currentX += frame.size.width + kDownloadItemPadding;
299 - (void)layoutItems {
300 [self layoutItems:NO];
303 - (void)addDownloadItem:(DownloadItem*)downloadItem {
304 DCHECK([NSThread isMainThread]);
305 base::scoped_nsobject<DownloadItemController> controller(
306 [[DownloadItemController alloc] initWithDownload:downloadItem
308 navigator:navigator_]);
309 [self add:controller.get()];
312 - (void)add:(DownloadItemController*)controller {
313 DCHECK([NSThread isMainThread]);
314 [self cancelAutoClose];
315 shouldCloseOnMouseExit_ = NO;
317 // Insert new item at the left.
318 // Adding at index 0 in NSMutableArrays is O(1).
319 [downloadItemControllers_ insertObject:controller atIndex:0];
321 [itemContainerView_ addSubview:[controller view]];
323 // The controller is in charge of removing itself as an observer in its
325 [[NSNotificationCenter defaultCenter]
326 addObserver:controller
327 selector:@selector(updateVisibility:)
328 name:NSViewFrameDidChangeNotification
329 object:[controller view]];
330 [[NSNotificationCenter defaultCenter]
331 addObserver:controller
332 selector:@selector(updateVisibility:)
333 name:NSViewFrameDidChangeNotification
334 object:itemContainerView_];
336 // Start at width 0...
337 NSSize size = [controller preferredSize];
338 NSRect frame = NSMakeRect(0, 0, 0, size.height);
339 [[controller view] setFrame:frame];
341 // ...then animate in
342 frame.size.width = size.width;
343 [NSAnimationContext beginGrouping];
344 [[NSAnimationContext currentContext]
345 gtm_setDuration:kDownloadItemOpenDuration
346 eventMask:NSLeftMouseUpMask];
347 [[[controller view] animator] setFrame:frame];
348 [NSAnimationContext endGrouping];
350 // Keep only a limited number of items in the shelf.
351 if ([downloadItemControllers_ count] > kMaxDownloadItemCount) {
352 DCHECK(kMaxDownloadItemCount > 0);
354 // Since no user will ever see the item being removed (needs a horizontal
355 // screen resolution greater than 3200 at 16 items at 200 pixels each),
356 // there's no point in animating the removal.
357 [self removeDownload:[downloadItemControllers_ lastObject]
361 // Finally, move the remaining items to the right. Skip the first item when
362 // laying out the items, so that the longer animation duration we set up above
363 // is not overwritten.
364 [self layoutItems:YES];
368 // Don't remove completed downloads if the shelf is just being auto-hidden
369 // rather than explicitly closed by the user.
370 if (bridge_->is_hidden())
373 while (i < [downloadItemControllers_ count]) {
374 DownloadItemController* itemController =
375 [downloadItemControllers_ objectAtIndex:i];
376 DownloadItem* download = [itemController download];
377 DownloadItem::DownloadState state = download->GetState();
378 bool isTransferDone = state == DownloadItem::COMPLETE ||
379 state == DownloadItem::CANCELLED ||
380 state == DownloadItem::INTERRUPTED;
381 if (isTransferDone && !download->IsDangerous()) {
382 [self removeDownload:itemController
385 // Treat the item as opened when we close. This way if we get shown again
386 // the user need not open this item for the shelf to auto-close.
387 download->SetOpened(true);
393 - (void)mouseEntered:(NSEvent*)event {
394 isMouseInsideView_ = YES;
395 // If the mouse re-enters the download shelf, cancel the auto-close. Further
396 // mouse exits should not trigger autoclose.
397 if (shouldCloseOnMouseExit_) {
398 [self cancelAutoClose];
399 shouldCloseOnMouseExit_ = NO;
403 - (void)mouseExited:(NSEvent*)event {
404 isMouseInsideView_ = NO;
405 if (shouldCloseOnMouseExit_)
406 [self scheduleAutoClose];
409 - (void)scheduleAutoClose {
410 // Cancel any previous hide requests, just to be safe.
411 [self cancelAutoClose];
413 // Schedule an autoclose after a delay. If the mouse is moved back into the
414 // view, or if an item is added to the shelf, the timer will be canceled.
415 [self performSelector:@selector(autoClose)
417 afterDelay:kAutoCloseDelaySeconds];
420 - (void)cancelAutoClose {
421 [NSObject cancelPreviousPerformRequestsWithTarget:self
422 selector:@selector(autoClose)
426 - (void)maybeAutoCloseAfterDelay {
427 // We can close the shelf automatically if all the downloads on the shelf have
429 for (NSUInteger i = 0; i < [downloadItemControllers_ count]; ++i) {
430 DownloadItemController* itemController =
431 [downloadItemControllers_ objectAtIndex:i];
432 if (![itemController download]->GetOpened())
436 if ([self isVisible] && [downloadItemControllers_ count] > 0 &&
437 isMouseInsideView_) {
438 // If there are download items on the shelf and the user is potentially stil
439 // interacting with them, schedule an auto close after the user moves the
440 // mouse off the shelf.
441 shouldCloseOnMouseExit_ = YES;
443 // We notify the DownloadShelf of our intention to close even if the shelf
444 // is currently hidden. If the shelf was temporarily hidden (e.g. because
445 // the browser window entered fullscreen mode), then this prevents the shelf
446 // from being shown again when the browser exits fullscreen mode.
452 bridge_->Close(DownloadShelf::AUTOMATIC);
455 - (void)installTrackingArea {
456 // Install the tracking area to listen for mouseEntered and mouseExited
458 DCHECK(!trackingArea_.get());
460 trackingArea_.reset([[CrTrackingArea alloc]
461 initWithRect:[[self view] bounds]
462 options:NSTrackingMouseEnteredAndExited |
463 NSTrackingActiveAlways |
464 NSTrackingInVisibleRect
467 [[self view] addTrackingArea:trackingArea_.get()];
470 - (void)removeTrackingArea {
471 if (trackingArea_.get()) {
472 [[self view] removeTrackingArea:trackingArea_.get()];
473 trackingArea_.reset(nil);
477 - (void)willEnterFullscreen {
479 [self updateCloseButton];
482 - (void)willLeaveFullscreen {
484 [self updateCloseButton];
487 - (void)updateCloseButton {
491 NSRect selfBounds = [[self view] bounds];
492 NSRect hoverFrame = [hoverCloseButton_ frame];
496 bounds = NSMakeRect(NSMinX(hoverFrame), 0,
497 selfBounds.size.width - NSMinX(hoverFrame),
498 selfBounds.size.height);
500 bounds.origin.x = NSMinX(hoverFrame);
501 bounds.origin.y = NSMidY(hoverFrame) -
502 kHoverCloseButtonDefaultSize.height / 2.0;
503 bounds.size = kHoverCloseButtonDefaultSize;
506 // Set the tracking off to create a new tracking area for the control.
507 // When changing the bounds/frame on a HoverButton, the tracking isn't updated
508 // correctly, it needs to be turned off and back on.
509 [hoverCloseButton_ setTrackingEnabled:NO];
510 [hoverCloseButton_ setFrame:bounds];
511 [hoverCloseButton_ setTrackingEnabled:YES];