blob: 93ec81b3c23514da7e445fea95d11a01ea50d1c5 [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/download/download_shelf_controller.h"
#include "base/mac/bundle_locations.h"
#include "base/mac/mac_util.h"
#include "base/strings/sys_string_conversions.h"
#include "chrome/browser/download/download_stats.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/themes/theme_service.h"
#include "chrome/browser/themes/theme_service_factory.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/chrome_pages.h"
#import "chrome/browser/ui/cocoa/animatable_view.h"
#include "chrome/browser/ui/cocoa/browser_window_cocoa.h"
#import "chrome/browser/ui/cocoa/browser_window_controller.h"
#include "chrome/browser/ui/cocoa/download/download_item_controller.h"
#include "chrome/browser/ui/cocoa/download/download_shelf_mac.h"
#import "chrome/browser/ui/cocoa/download/download_shelf_view.h"
#import "chrome/browser/ui/cocoa/presentation_mode_controller.h"
#include "content/public/browser/download_item.h"
#include "content/public/browser/download_manager.h"
#import "third_party/google_toolbox_for_mac/src/AppKit/GTMNSAnimation+Duration.h"
#import "ui/base/cocoa/hover_button.h"
using content::DownloadItem;
// Download shelf autoclose behavior:
//
// The download shelf autocloses if all of this is true:
// 1) An item on the shelf has just been opened or removed.
// 2) All remaining items on the shelf have been opened in the past.
// 3) The mouse leaves the shelf and remains off the shelf for 5 seconds.
//
// If the mouse re-enters the shelf within the 5 second grace period, the
// autoclose is canceled. An autoclose can only be scheduled in response to a
// shelf item being opened or removed. If an item is opened and then the
// resulting autoclose is canceled, subsequent mouse exited events will NOT
// trigger an autoclose.
//
// If the shelf is manually closed while a download is still in progress, that
// download is marked as "opened" for these purposes. If the shelf is later
// reopened, these previously-in-progress download will not block autoclose,
// even if that download was never actually clicked on and opened.
namespace {
// Max number of download views we'll contain. Any time a view is added and
// we already have this many download views, one is removed.
const size_t kMaxDownloadItemCount = 16;
// Horizontal padding between two download items.
const int kDownloadItemPadding = 0;
// Duration for the open-new-leftmost-item animation, in seconds.
const NSTimeInterval kDownloadItemOpenDuration = 0.8;
// Duration for download shelf closing animation, in seconds.
const NSTimeInterval kDownloadShelfCloseDuration = 0.12;
// Amount of time between when the mouse is moved off the shelf and the shelf is
// autoclosed, in seconds.
const NSTimeInterval kAutoCloseDelaySeconds = 5;
// The size of the x button by default.
const NSSize kHoverCloseButtonDefaultSize = { 18, 18 };
} // namespace
@interface DownloadShelfController(Private)
- (void)removeDownload:(DownloadItemController*)download
isShelfClosing:(BOOL)isShelfClosing;
- (void)layoutItems:(BOOL)skipFirst;
- (void)closed;
- (void)maybeAutoCloseAfterDelay;
- (void)scheduleAutoClose;
- (void)cancelAutoClose;
- (void)autoClose;
- (void)viewFrameDidChange:(NSNotification*)notification;
- (void)installTrackingArea;
- (void)removeTrackingArea;
- (void)willEnterFullscreen;
- (void)willLeaveFullscreen;
- (void)updateCloseButton;
@end
@implementation DownloadShelfController
- (id)initWithBrowser:(Browser*)browser
resizeDelegate:(id<ViewResizer>)resizeDelegate {
if ((self = [super initWithNibName:@"DownloadShelf"
bundle:base::mac::FrameworkBundle()])) {
resizeDelegate_ = resizeDelegate;
maxShelfHeight_ = NSHeight([[self view] bounds]);
currentShelfHeight_ = maxShelfHeight_;
if (browser && browser->window())
isFullscreen_ = browser->window()->IsFullscreen();
else
isFullscreen_ = NO;
// Reset the download shelf's frame height to zero. It will be properly
// positioned and sized the first time we try to set its height. (Just
// setting the rect to NSZeroRect does not work: it confuses Cocoa's view
// layout logic. If the shelf's width is too small, cocoa makes the download
// item container view wider than the browser window).
NSRect frame = [[self view] frame];
frame.size.height = 0;
[[self view] setFrame:frame];
downloadItemControllers_.reset([[NSMutableArray alloc] init]);
bridge_.reset(new DownloadShelfMac(browser, self));
navigator_ = browser;
}
return self;
}
- (void)awakeFromNib {
DCHECK(hoverCloseButton_);
NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
[[self animatableView] setResizeDelegate:resizeDelegate_];
[[self view] setPostsFrameChangedNotifications:YES];
[defaultCenter addObserver:self
selector:@selector(viewFrameDidChange:)
name:NSViewFrameDidChangeNotification
object:[self view]];
// These notifications are declared in fullscreen_controller, and are posted
// without objects.
[defaultCenter addObserver:self
selector:@selector(willEnterFullscreen)
name:kWillEnterFullscreenNotification
object:nil];
[defaultCenter addObserver:self
selector:@selector(willLeaveFullscreen)
name:kWillLeaveFullscreenNotification
object:nil];
[self installTrackingArea];
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
[self cancelAutoClose];
[self removeTrackingArea];
// The controllers will unregister themselves as observers when they are
// deallocated. No need to do that here.
[super dealloc];
}
// Called after the frame's rect has changed; usually when the height is
// animated.
- (void)viewFrameDidChange:(NSNotification*)notification {
// Anchor subviews at the top of |view|, so that it looks like the shelf
// is sliding out.
CGFloat newShelfHeight = NSHeight([[self view] frame]);
if (newShelfHeight == currentShelfHeight_)
return;
for (NSView* view in [[self view] subviews]) {
NSRect frame = [view frame];
frame.origin.y -= currentShelfHeight_ - newShelfHeight;
[view setFrame:frame];
}
currentShelfHeight_ = newShelfHeight;
}
- (AnimatableView*)animatableView {
return static_cast<AnimatableView*>([self view]);
}
- (IBAction)showDownloadsTab:(id)sender {
chrome::ShowDownloads(bridge_->browser());
}
- (IBAction)handleClose:(id)sender {
bridge_->Close(DownloadShelf::USER_ACTION);
}
- (void)remove:(DownloadItemController*)download {
[self removeDownload:download
isShelfClosing:NO];
}
- (void)removeDownload:(DownloadItemController*)download
isShelfClosing:(BOOL)isShelfClosing {
// Look for the download in our controller array and remove it. This will
// explicity release it so that it removes itself as an Observer of the
// DownloadItem. We don't want to wait for autorelease since the DownloadItem
// we are observing will likely be gone by then.
[[NSNotificationCenter defaultCenter] removeObserver:download];
// TODO(dmaclach): Remove -- http://crbug.com/25845
[[download view] removeFromSuperview];
[downloadItemControllers_ removeObject:download];
if (!isShelfClosing) {
[self layoutItems];
// If there are no more downloads or if all the remaining downloads have
// been opened, we can close the shelf.
[self maybeAutoCloseAfterDelay];
}
}
- (void)downloadWasOpened:(DownloadItemController*)item_controller {
// This should only be called on the main thead.
DCHECK([NSThread isMainThread]);
[self maybeAutoCloseAfterDelay];
}
// We need to explicitly release our download controllers here since they need
// to remove themselves as observers before the remaining shutdown happens.
- (void)exiting {
[[self animatableView] stopAnimation];
[self removeTrackingArea];
[self cancelAutoClose];
while ([downloadItemControllers_ count] > 0) {
[self removeDownload:[downloadItemControllers_ lastObject]
isShelfClosing:YES];
}
downloadItemControllers_.reset();
}
- (void)showDownloadShelf:(BOOL)show
isUserAction:(BOOL)isUserAction {
[self cancelAutoClose];
shouldCloseOnMouseExit_ = NO;
if ([self isVisible] == show)
return;
if (!show) {
int numInProgress = 0;
for (NSUInteger i = 0; i < [downloadItemControllers_ count]; ++i) {
DownloadItem* item = [[downloadItemControllers_ objectAtIndex:i]download];
if (item->GetState() == DownloadItem::IN_PROGRESS)
++numInProgress;
}
RecordDownloadShelfClose(
[downloadItemControllers_ count], numInProgress, !isUserAction);
}
// Animate the shelf out, but not in.
// TODO(rohitrao): We do not animate on the way in because Cocoa is already
// doing a lot of work to set up the download arrow animation. I've chosen to
// do no animation over janky animation. Find a way to make animating in
// smoother.
AnimatableView* view = [self animatableView];
if (show) {
[view setHeight:maxShelfHeight_];
[view setHidden:NO];
} else {
[view animateToNewHeight:0 duration:kDownloadShelfCloseDuration];
}
barIsVisible_ = show;
[self updateCloseButton];
}
- (DownloadShelf*)bridge {
return bridge_.get();
}
- (BOOL)isVisible {
return barIsVisible_;
}
- (void)animationDidEnd:(NSAnimation*)animation {
if (![self isVisible]) {
[self closed];
[[self view] setHidden:YES]; // So that it doesn't appear in AX hierarchy.
}
}
- (float)height {
return maxShelfHeight_;
}
// If |skipFirst| is true, the frame of the leftmost item is not set.
- (void)layoutItems:(BOOL)skipFirst {
CGFloat currentX = 0;
for (DownloadItemController* itemController
in downloadItemControllers_.get()) {
NSRect frame = [[itemController view] frame];
frame.origin.x = currentX;
frame.size.width = [itemController preferredSize].width;
if (!skipFirst)
[[[itemController view] animator] setFrame:frame];
currentX += frame.size.width + kDownloadItemPadding;
skipFirst = NO;
}
}
- (void)layoutItems {
[self layoutItems:NO];
}
- (void)addDownloadItem:(DownloadItem*)downloadItem {
DCHECK([NSThread isMainThread]);
base::scoped_nsobject<DownloadItemController> controller(
[[DownloadItemController alloc] initWithDownload:downloadItem
shelf:self
navigator:navigator_]);
[self add:controller.get()];
}
- (void)add:(DownloadItemController*)controller {
DCHECK([NSThread isMainThread]);
[self cancelAutoClose];
shouldCloseOnMouseExit_ = NO;
// Insert new item at the left.
// Adding at index 0 in NSMutableArrays is O(1).
[downloadItemControllers_ insertObject:controller atIndex:0];
[itemContainerView_ addSubview:[controller view]];
// The controller is in charge of removing itself as an observer in its
// dealloc.
[[NSNotificationCenter defaultCenter]
addObserver:controller
selector:@selector(updateVisibility:)
name:NSViewFrameDidChangeNotification
object:[controller view]];
[[NSNotificationCenter defaultCenter]
addObserver:controller
selector:@selector(updateVisibility:)
name:NSViewFrameDidChangeNotification
object:itemContainerView_];
// Start at width 0...
NSSize size = [controller preferredSize];
NSRect frame = NSMakeRect(0, 0, 0, size.height);
[[controller view] setFrame:frame];
// ...then animate in
frame.size.width = size.width;
[NSAnimationContext beginGrouping];
[[NSAnimationContext currentContext]
gtm_setDuration:kDownloadItemOpenDuration
eventMask:NSLeftMouseUpMask];
[[[controller view] animator] setFrame:frame];
[NSAnimationContext endGrouping];
// Keep only a limited number of items in the shelf.
if ([downloadItemControllers_ count] > kMaxDownloadItemCount) {
DCHECK(kMaxDownloadItemCount > 0);
// Since no user will ever see the item being removed (needs a horizontal
// screen resolution greater than 3200 at 16 items at 200 pixels each),
// there's no point in animating the removal.
[self removeDownload:[downloadItemControllers_ lastObject]
isShelfClosing:NO];
}
// Finally, move the remaining items to the right. Skip the first item when
// laying out the items, so that the longer animation duration we set up above
// is not overwritten.
[self layoutItems:YES];
}
- (void)closed {
// Don't remove completed downloads if the shelf is just being auto-hidden
// rather than explicitly closed by the user.
if (bridge_->is_hidden())
return;
NSUInteger i = 0;
while (i < [downloadItemControllers_ count]) {
DownloadItemController* itemController =
[downloadItemControllers_ objectAtIndex:i];
DownloadItem* download = [itemController download];
DownloadItem::DownloadState state = download->GetState();
bool isTransferDone = state == DownloadItem::COMPLETE ||
state == DownloadItem::CANCELLED ||
state == DownloadItem::INTERRUPTED;
if (isTransferDone && !download->IsDangerous()) {
[self removeDownload:itemController
isShelfClosing:YES];
} else {
// Treat the item as opened when we close. This way if we get shown again
// the user need not open this item for the shelf to auto-close.
download->SetOpened(true);
++i;
}
}
}
- (void)mouseEntered:(NSEvent*)event {
isMouseInsideView_ = YES;
// If the mouse re-enters the download shelf, cancel the auto-close. Further
// mouse exits should not trigger autoclose.
if (shouldCloseOnMouseExit_) {
[self cancelAutoClose];
shouldCloseOnMouseExit_ = NO;
}
}
- (void)mouseExited:(NSEvent*)event {
isMouseInsideView_ = NO;
if (shouldCloseOnMouseExit_)
[self scheduleAutoClose];
}
- (void)scheduleAutoClose {
// Cancel any previous hide requests, just to be safe.
[self cancelAutoClose];
// Schedule an autoclose after a delay. If the mouse is moved back into the
// view, or if an item is added to the shelf, the timer will be canceled.
[self performSelector:@selector(autoClose)
withObject:nil
afterDelay:kAutoCloseDelaySeconds];
}
- (void)cancelAutoClose {
[NSObject cancelPreviousPerformRequestsWithTarget:self
selector:@selector(autoClose)
object:nil];
}
- (void)maybeAutoCloseAfterDelay {
// We can close the shelf automatically if all the downloads on the shelf have
// been opened.
for (NSUInteger i = 0; i < [downloadItemControllers_ count]; ++i) {
DownloadItemController* itemController =
[downloadItemControllers_ objectAtIndex:i];
if (![itemController download]->GetOpened())
return;
}
if ([self isVisible] && [downloadItemControllers_ count] > 0 &&
isMouseInsideView_) {
// If there are download items on the shelf and the user is potentially stil
// interacting with them, schedule an auto close after the user moves the
// mouse off the shelf.
shouldCloseOnMouseExit_ = YES;
} else {
// We notify the DownloadShelf of our intention to close even if the shelf
// is currently hidden. If the shelf was temporarily hidden (e.g. because
// the browser window entered fullscreen mode), then this prevents the shelf
// from being shown again when the browser exits fullscreen mode.
[self autoClose];
}
}
- (void)autoClose {
bridge_->Close(DownloadShelf::AUTOMATIC);
}
- (void)installTrackingArea {
// Install the tracking area to listen for mouseEntered and mouseExited
// messages.
DCHECK(!trackingArea_.get());
trackingArea_.reset([[CrTrackingArea alloc]
initWithRect:[[self view] bounds]
options:NSTrackingMouseEnteredAndExited |
NSTrackingActiveAlways |
NSTrackingInVisibleRect
owner:self
userInfo:nil]);
[[self view] addTrackingArea:trackingArea_.get()];
}
- (void)removeTrackingArea {
if (trackingArea_.get()) {
[[self view] removeTrackingArea:trackingArea_.get()];
trackingArea_.reset(nil);
}
}
- (void)willEnterFullscreen {
isFullscreen_ = YES;
[self updateCloseButton];
}
- (void)willLeaveFullscreen {
isFullscreen_ = NO;
[self updateCloseButton];
}
- (void)updateCloseButton {
if (!barIsVisible_)
return;
NSRect selfBounds = [[self view] bounds];
NSRect hoverFrame = [hoverCloseButton_ frame];
NSRect bounds;
if (isFullscreen_) {
bounds = NSMakeRect(NSMinX(hoverFrame), 0,
selfBounds.size.width - NSMinX(hoverFrame),
selfBounds.size.height);
} else {
bounds.origin.x = NSMinX(hoverFrame);
bounds.origin.y = NSMidY(hoverFrame) -
kHoverCloseButtonDefaultSize.height / 2.0;
bounds.size = kHoverCloseButtonDefaultSize;
}
// Set the tracking off to create a new tracking area for the control.
// When changing the bounds/frame on a HoverButton, the tracking isn't updated
// correctly, it needs to be turned off and back on.
[hoverCloseButton_ setTrackingEnabled:NO];
[hoverCloseButton_ setFrame:bounds];
[hoverCloseButton_ setTrackingEnabled:YES];
}
@end