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/mac/mac_util.h"
9 #include "base/strings/sys_string_conversions.h"
10 #include "chrome/browser/download/download_stats.h"
11 #include "chrome/browser/profiles/profile.h"
12 #include "chrome/browser/themes/theme_service.h"
13 #include "chrome/browser/themes/theme_service_factory.h"
14 #include "chrome/browser/ui/browser.h"
15 #include "chrome/browser/ui/chrome_pages.h"
16 #import "chrome/browser/ui/cocoa/animatable_view.h"
17 #include "chrome/browser/ui/cocoa/browser_window_cocoa.h"
18 #import "chrome/browser/ui/cocoa/browser_window_controller.h"
19 #include "chrome/browser/ui/cocoa/download/download_item_controller.h"
20 #include "chrome/browser/ui/cocoa/download/download_shelf_mac.h"
21 #import "chrome/browser/ui/cocoa/download/download_shelf_view.h"
22 #import "chrome/browser/ui/cocoa/presentation_mode_controller.h"
23 #include "content/public/browser/download_item.h"
24 #include "content/public/browser/download_manager.h"
25 #import "third_party/google_toolbox_for_mac/src/AppKit/GTMNSAnimation+Duration.h"
26 #import "ui/base/cocoa/hover_button.h"
27 #include "ui/base/l10n/l10n_util.h"
29 using content::DownloadItem;
31 // Download shelf autoclose behavior:
33 // The download shelf autocloses if all of this is true:
34 // 1) An item on the shelf has just been opened or removed.
35 // 2) All remaining items on the shelf have been opened in the past.
36 // 3) The mouse leaves the shelf and remains off the shelf for 5 seconds.
38 // If the mouse re-enters the shelf within the 5 second grace period, the
39 // autoclose is canceled. An autoclose can only be scheduled in response to a
40 // shelf item being opened or removed. If an item is opened and then the
41 // resulting autoclose is canceled, subsequent mouse exited events will NOT
42 // trigger an autoclose.
44 // If the shelf is manually closed while a download is still in progress, that
45 // download is marked as "opened" for these purposes. If the shelf is later
46 // reopened, these previously-in-progress download will not block autoclose,
47 // even if that download was never actually clicked on and opened.
51 // Max number of download views we'll contain. Any time a view is added and
52 // we already have this many download views, one is removed.
53 const size_t kMaxDownloadItemCount = 16;
55 // Horizontal padding between two download items.
56 const int kDownloadItemPadding = 0;
58 // Duration for the open-new-leftmost-item animation, in seconds.
59 const NSTimeInterval kDownloadItemOpenDuration = 0.8;
61 // Duration for download shelf closing animation, in seconds.
62 const NSTimeInterval kDownloadShelfCloseDuration = 0.12;
64 // Amount of time between when the mouse is moved off the shelf and the shelf is
65 // autoclosed, in seconds.
66 const NSTimeInterval kAutoCloseDelaySeconds = 5;
68 // The size of the x button by default.
69 const NSSize kHoverCloseButtonDefaultSize = { 18, 18 };
73 @interface DownloadShelfController(Private)
74 - (void)removeDownload:(DownloadItemController*)download
75 isShelfClosing:(BOOL)isShelfClosing;
76 - (void)layoutItems:(BOOL)skipFirst;
78 - (void)maybeAutoCloseAfterDelay;
79 - (void)scheduleAutoClose;
80 - (void)cancelAutoClose;
82 - (void)viewFrameDidChange:(NSNotification*)notification;
83 - (void)installTrackingArea;
84 - (void)removeTrackingArea;
85 - (void)willEnterFullscreen;
86 - (void)willLeaveFullscreen;
87 - (void)updateCloseButton;
91 @implementation DownloadShelfController
93 - (id)initWithBrowser:(Browser*)browser
94 resizeDelegate:(id<ViewResizer>)resizeDelegate {
95 if ((self = [super initWithNibName:@"DownloadShelf"
96 bundle:base::mac::FrameworkBundle()])) {
97 resizeDelegate_ = resizeDelegate;
98 maxShelfHeight_ = NSHeight([[self view] bounds]);
99 currentShelfHeight_ = maxShelfHeight_;
100 if (browser && browser->window())
101 isFullscreen_ = browser->window()->IsFullscreen();
105 // Reset the download shelf's frame height to zero. It will be properly
106 // positioned and sized the first time we try to set its height. (Just
107 // setting the rect to NSZeroRect does not work: it confuses Cocoa's view
108 // layout logic. If the shelf's width is too small, cocoa makes the download
109 // item container view wider than the browser window).
110 NSRect frame = [[self view] frame];
111 frame.size.height = 0;
112 [[self view] setFrame:frame];
114 downloadItemControllers_.reset([[NSMutableArray alloc] init]);
116 bridge_.reset(new DownloadShelfMac(browser, self));
117 navigator_ = browser;
122 - (void)awakeFromNib {
123 DCHECK(hoverCloseButton_);
125 NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
126 [[self animatableView] setResizeDelegate:resizeDelegate_];
127 [[self view] setPostsFrameChangedNotifications:YES];
128 [defaultCenter addObserver:self
129 selector:@selector(viewFrameDidChange:)
130 name:NSViewFrameDidChangeNotification
133 // These notifications are declared in fullscreen_controller, and are posted
135 [defaultCenter addObserver:self
136 selector:@selector(willEnterFullscreen)
137 name:kWillEnterFullscreenNotification
139 [defaultCenter addObserver:self
140 selector:@selector(willLeaveFullscreen)
141 name:kWillLeaveFullscreenNotification
143 [self installTrackingArea];
147 [[NSNotificationCenter defaultCenter] removeObserver:self];
148 [self cancelAutoClose];
149 [self removeTrackingArea];
151 // The controllers will unregister themselves as observers when they are
152 // deallocated. No need to do that here.
156 // Called after the frame's rect has changed; usually when the height is
158 - (void)viewFrameDidChange:(NSNotification*)notification {
159 // Anchor subviews at the top of |view|, so that it looks like the shelf
161 CGFloat newShelfHeight = NSHeight([[self view] frame]);
162 if (newShelfHeight == currentShelfHeight_)
165 for (NSView* view in [[self view] subviews]) {
166 NSRect frame = [view frame];
167 frame.origin.y -= currentShelfHeight_ - newShelfHeight;
168 [view setFrame:frame];
170 currentShelfHeight_ = newShelfHeight;
173 - (AnimatableView*)animatableView {
174 return static_cast<AnimatableView*>([self view]);
177 - (IBAction)showDownloadsTab:(id)sender {
178 chrome::ShowDownloads(bridge_->browser());
181 - (IBAction)handleClose:(id)sender {
182 bridge_->Close(DownloadShelf::USER_ACTION);
185 - (void)remove:(DownloadItemController*)download {
186 [self removeDownload:download
190 - (void)removeDownload:(DownloadItemController*)download
191 isShelfClosing:(BOOL)isShelfClosing {
192 // Look for the download in our controller array and remove it. This will
193 // explicity release it so that it removes itself as an Observer of the
194 // DownloadItem. We don't want to wait for autorelease since the DownloadItem
195 // we are observing will likely be gone by then.
196 [[NSNotificationCenter defaultCenter] removeObserver:download];
198 // TODO(dmaclach): Remove -- http://crbug.com/25845
199 [[download view] removeFromSuperview];
201 [downloadItemControllers_ removeObject:download];
203 if (!isShelfClosing) {
206 // If there are no more downloads or if all the remaining downloads have
207 // been opened, we can close the shelf.
208 [self maybeAutoCloseAfterDelay];
212 - (void)downloadWasOpened:(DownloadItemController*)item_controller {
213 // This should only be called on the main thead.
214 DCHECK([NSThread isMainThread]);
215 [self maybeAutoCloseAfterDelay];
218 // We need to explicitly release our download controllers here since they need
219 // to remove themselves as observers before the remaining shutdown happens.
221 [[self animatableView] stopAnimation];
222 [self removeTrackingArea];
223 [self cancelAutoClose];
224 while ([downloadItemControllers_ count] > 0) {
225 [self removeDownload:[downloadItemControllers_ lastObject]
228 downloadItemControllers_.reset();
231 - (void)showDownloadShelf:(BOOL)show
232 isUserAction:(BOOL)isUserAction {
233 [self cancelAutoClose];
234 shouldCloseOnMouseExit_ = NO;
236 if ([self isVisible] == show)
240 int numInProgress = 0;
241 for (NSUInteger i = 0; i < [downloadItemControllers_ count]; ++i) {
242 DownloadItem* item = [[downloadItemControllers_ objectAtIndex:i]download];
243 if (item->GetState() == DownloadItem::IN_PROGRESS)
246 RecordDownloadShelfClose(
247 [downloadItemControllers_ count], numInProgress, !isUserAction);
250 // Animate the shelf out, but not in.
251 // TODO(rohitrao): We do not animate on the way in because Cocoa is already
252 // doing a lot of work to set up the download arrow animation. I've chosen to
253 // do no animation over janky animation. Find a way to make animating in
255 AnimatableView* view = [self animatableView];
257 [view setHeight:maxShelfHeight_];
259 [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])
279 return maxShelfHeight_;
282 // If |skipFirst| is true, the frame of the leftmost item is not set.
283 - (void)layoutItems:(BOOL)skipFirst {
284 CGFloat currentX = 0;
285 for (DownloadItemController* itemController
286 in downloadItemControllers_.get()) {
287 NSRect frame = [[itemController view] frame];
288 frame.origin.x = currentX;
289 frame.size.width = [itemController preferredSize].width;
291 [[[itemController view] animator] setFrame:frame];
292 currentX += frame.size.width + kDownloadItemPadding;
297 - (void)layoutItems {
298 [self layoutItems:NO];
301 - (void)addDownloadItem:(DownloadItem*)downloadItem {
302 DCHECK([NSThread isMainThread]);
303 base::scoped_nsobject<DownloadItemController> controller(
304 [[DownloadItemController alloc] initWithDownload:downloadItem
306 navigator:navigator_]);
307 [self add:controller.get()];
310 - (void)add:(DownloadItemController*)controller {
311 DCHECK([NSThread isMainThread]);
312 [self cancelAutoClose];
313 shouldCloseOnMouseExit_ = NO;
315 // Insert new item at the left.
316 // Adding at index 0 in NSMutableArrays is O(1).
317 [downloadItemControllers_ insertObject:controller atIndex:0];
319 [itemContainerView_ addSubview:[controller view]];
321 // The controller is in charge of removing itself as an observer in its
323 [[NSNotificationCenter defaultCenter]
324 addObserver:controller
325 selector:@selector(updateVisibility:)
326 name:NSViewFrameDidChangeNotification
327 object:[controller view]];
328 [[NSNotificationCenter defaultCenter]
329 addObserver:controller
330 selector:@selector(updateVisibility:)
331 name:NSViewFrameDidChangeNotification
332 object:itemContainerView_];
334 // Start at width 0...
335 NSSize size = [controller preferredSize];
336 NSRect frame = NSMakeRect(0, 0, 0, size.height);
337 [[controller view] setFrame:frame];
339 // ...then animate in
340 frame.size.width = size.width;
341 [NSAnimationContext beginGrouping];
342 [[NSAnimationContext currentContext]
343 gtm_setDuration:kDownloadItemOpenDuration
344 eventMask:NSLeftMouseUpMask];
345 [[[controller view] animator] setFrame:frame];
346 [NSAnimationContext endGrouping];
348 // Keep only a limited number of items in the shelf.
349 if ([downloadItemControllers_ count] > kMaxDownloadItemCount) {
350 DCHECK(kMaxDownloadItemCount > 0);
352 // Since no user will ever see the item being removed (needs a horizontal
353 // screen resolution greater than 3200 at 16 items at 200 pixels each),
354 // there's no point in animating the removal.
355 [self removeDownload:[downloadItemControllers_ lastObject]
359 // Finally, move the remaining items to the right. Skip the first item when
360 // laying out the items, so that the longer animation duration we set up above
361 // is not overwritten.
362 [self layoutItems:YES];
366 // Don't remove completed downloads if the shelf is just being auto-hidden
367 // rather than explicitly closed by the user.
368 if (bridge_->is_hidden())
371 while (i < [downloadItemControllers_ count]) {
372 DownloadItemController* itemController =
373 [downloadItemControllers_ objectAtIndex:i];
374 DownloadItem* download = [itemController download];
375 DownloadItem::DownloadState state = download->GetState();
376 bool isTransferDone = state == DownloadItem::COMPLETE ||
377 state == DownloadItem::CANCELLED ||
378 state == DownloadItem::INTERRUPTED;
379 if (isTransferDone && !download->IsDangerous()) {
380 [self removeDownload:itemController
383 // Treat the item as opened when we close. This way if we get shown again
384 // the user need not open this item for the shelf to auto-close.
385 download->SetOpened(true);
391 - (void)mouseEntered:(NSEvent*)event {
392 isMouseInsideView_ = YES;
393 // If the mouse re-enters the download shelf, cancel the auto-close. Further
394 // mouse exits should not trigger autoclose.
395 if (shouldCloseOnMouseExit_) {
396 [self cancelAutoClose];
397 shouldCloseOnMouseExit_ = NO;
401 - (void)mouseExited:(NSEvent*)event {
402 isMouseInsideView_ = NO;
403 if (shouldCloseOnMouseExit_)
404 [self scheduleAutoClose];
407 - (void)scheduleAutoClose {
408 // Cancel any previous hide requests, just to be safe.
409 [self cancelAutoClose];
411 // Schedule an autoclose after a delay. If the mouse is moved back into the
412 // view, or if an item is added to the shelf, the timer will be canceled.
413 [self performSelector:@selector(autoClose)
415 afterDelay:kAutoCloseDelaySeconds];
418 - (void)cancelAutoClose {
419 [NSObject cancelPreviousPerformRequestsWithTarget:self
420 selector:@selector(autoClose)
424 - (void)maybeAutoCloseAfterDelay {
425 // We can close the shelf automatically if all the downloads on the shelf have
427 for (NSUInteger i = 0; i < [downloadItemControllers_ count]; ++i) {
428 DownloadItemController* itemController =
429 [downloadItemControllers_ objectAtIndex:i];
430 if (![itemController download]->GetOpened())
434 if ([self isVisible] && [downloadItemControllers_ count] > 0 &&
435 isMouseInsideView_) {
436 // If there are download items on the shelf and the user is potentially stil
437 // interacting with them, schedule an auto close after the user moves the
438 // mouse off the shelf.
439 shouldCloseOnMouseExit_ = YES;
441 // We notify the DownloadShelf of our intention to close even if the shelf
442 // is currently hidden. If the shelf was temporarily hidden (e.g. because
443 // the browser window entered fullscreen mode), then this prevents the shelf
444 // from being shown again when the browser exits fullscreen mode.
450 bridge_->Close(DownloadShelf::AUTOMATIC);
453 - (void)installTrackingArea {
454 // Install the tracking area to listen for mouseEntered and mouseExited
456 DCHECK(!trackingArea_.get());
458 trackingArea_.reset([[CrTrackingArea alloc]
459 initWithRect:[[self view] bounds]
460 options:NSTrackingMouseEnteredAndExited |
461 NSTrackingActiveAlways |
462 NSTrackingInVisibleRect
465 [[self view] addTrackingArea:trackingArea_.get()];
468 - (void)removeTrackingArea {
469 if (trackingArea_.get()) {
470 [[self view] removeTrackingArea:trackingArea_.get()];
471 trackingArea_.reset(nil);
475 - (void)willEnterFullscreen {
477 [self updateCloseButton];
480 - (void)willLeaveFullscreen {
482 [self updateCloseButton];
485 - (void)updateCloseButton {
489 NSRect selfBounds = [[self view] bounds];
490 NSRect hoverFrame = [hoverCloseButton_ frame];
494 bounds = NSMakeRect(NSMinX(hoverFrame), 0,
495 selfBounds.size.width - NSMinX(hoverFrame),
496 selfBounds.size.height);
498 bounds.origin.x = NSMinX(hoverFrame);
499 bounds.origin.y = NSMidY(hoverFrame) -
500 kHoverCloseButtonDefaultSize.height / 2.0;
501 bounds.size = kHoverCloseButtonDefaultSize;
504 // Set the tracking off to create a new tracking area for the control.
505 // When changing the bounds/frame on a HoverButton, the tracking isn't updated
506 // correctly, it needs to be turned off and back on.
507 [hoverCloseButton_ setTrackingEnabled:NO];
508 [hoverCloseButton_ setFrame:bounds];
509 [hoverCloseButton_ setTrackingEnabled:YES];