Home | History | Annotate | Download | only in download
      1 // Copyright (c) 2011 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/download/download_shelf_controller.h"
      6 
      7 #include "base/mac/mac_util.h"
      8 #include "base/sys_string_conversions.h"
      9 #include "chrome/browser/download/download_item.h"
     10 #include "chrome/browser/download/download_manager.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 #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.h"
     21 #import "chrome/browser/ui/cocoa/fullscreen_controller.h"
     22 #import "chrome/browser/ui/cocoa/hover_button.h"
     23 #import "chrome/browser/ui/cocoa/hyperlink_button_cell.h"
     24 #include "grit/generated_resources.h"
     25 #include "grit/theme_resources.h"
     26 #import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h"
     27 #include "ui/base/l10n/l10n_util.h"
     28 #include "ui/base/resource/resource_bundle.h"
     29 #include "ui/gfx/image.h"
     30 
     31 // Download shelf autoclose behavior:
     32 //
     33 // The download shelf autocloses if all of this is true:
     34 // 1) An item on the shelf has just been opened.
     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.
     37 //
     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.
     43 //
     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.
     48 
     49 namespace {
     50 
     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;
     54 
     55 // Horizontal padding between two download items.
     56 const int kDownloadItemPadding = 0;
     57 
     58 // Duration for the open-new-leftmost-item animation, in seconds.
     59 const NSTimeInterval kDownloadItemOpenDuration = 0.8;
     60 
     61 // Duration for download shelf closing animation, in seconds.
     62 const NSTimeInterval kDownloadShelfCloseDuration = 0.12;
     63 
     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;
     67 
     68 // The size of the x button by default.
     69 const NSSize kHoverCloseButtonDefaultSize = { 16, 16 };
     70 
     71 }  // namespace
     72 
     73 @interface DownloadShelfController(Private)
     74 - (void)showDownloadShelf:(BOOL)enable;
     75 - (void)layoutItems:(BOOL)skipFirst;
     76 - (void)closed;
     77 - (BOOL)canAutoClose;
     78 
     79 - (void)updateTheme;
     80 - (void)themeDidChangeNotification:(NSNotification*)notification;
     81 - (void)viewFrameDidChange:(NSNotification*)notification;
     82 
     83 - (void)installTrackingArea;
     84 - (void)cancelAutoCloseAndRemoveTrackingArea;
     85 
     86 - (void)willEnterFullscreen;
     87 - (void)willLeaveFullscreen;
     88 - (void)updateCloseButton;
     89 @end
     90 
     91 
     92 @implementation DownloadShelfController
     93 
     94 - (id)initWithBrowser:(Browser*)browser
     95        resizeDelegate:(id<ViewResizer>)resizeDelegate {
     96   if ((self = [super initWithNibName:@"DownloadShelf"
     97                               bundle:base::mac::MainAppBundle()])) {
     98     resizeDelegate_ = resizeDelegate;
     99     maxShelfHeight_ = NSHeight([[self view] bounds]);
    100     currentShelfHeight_ = maxShelfHeight_;
    101     if (browser && browser->window())
    102       isFullscreen_ = browser->window()->IsFullscreen();
    103     else
    104       isFullscreen_ = NO;
    105 
    106     // Reset the download shelf's frame height to zero.  It will be properly
    107     // positioned and sized the first time we try to set its height. (Just
    108     // setting the rect to NSZeroRect does not work: it confuses Cocoa's view
    109     // layout logic. If the shelf's width is too small, cocoa makes the download
    110     // item container view wider than the browser window).
    111     NSRect frame = [[self view] frame];
    112     frame.size.height = 0;
    113     [[self view] setFrame:frame];
    114 
    115     downloadItemControllers_.reset([[NSMutableArray alloc] init]);
    116 
    117     bridge_.reset(new DownloadShelfMac(browser, self));
    118   }
    119   return self;
    120 }
    121 
    122 - (void)awakeFromNib {
    123   DCHECK(hoverCloseButton_);
    124 
    125   NSNotificationCenter* defaultCenter = [NSNotificationCenter defaultCenter];
    126   [defaultCenter addObserver:self
    127                     selector:@selector(themeDidChangeNotification:)
    128                         name:kBrowserThemeDidChangeNotification
    129                       object:nil];
    130 
    131   [[self animatableView] setResizeDelegate:resizeDelegate_];
    132   [[self view] setPostsFrameChangedNotifications:YES];
    133   [defaultCenter addObserver:self
    134                     selector:@selector(viewFrameDidChange:)
    135                         name:NSViewFrameDidChangeNotification
    136                       object:[self view]];
    137 
    138   ResourceBundle& rb = ResourceBundle::GetSharedInstance();
    139   NSImage* favicon = rb.GetNativeImageNamed(IDR_DOWNLOADS_FAVICON);
    140   DCHECK(favicon);
    141   [image_ setImage:favicon];
    142 
    143   // These notifications are declared in fullscreen_controller, and are posted
    144   // without objects.
    145   [defaultCenter addObserver:self
    146                     selector:@selector(willEnterFullscreen)
    147                         name:kWillEnterFullscreenNotification
    148                       object:nil];
    149   [defaultCenter addObserver:self
    150                     selector:@selector(willLeaveFullscreen)
    151                         name:kWillLeaveFullscreenNotification
    152                       object:nil];
    153 }
    154 
    155 - (void)dealloc {
    156   [[NSNotificationCenter defaultCenter] removeObserver:self];
    157   [self cancelAutoCloseAndRemoveTrackingArea];
    158 
    159   // The controllers will unregister themselves as observers when they are
    160   // deallocated. No need to do that here.
    161   [super dealloc];
    162 }
    163 
    164 // Called after the current theme has changed.
    165 - (void)themeDidChangeNotification:(NSNotification*)notification {
    166   [self updateTheme];
    167 }
    168 
    169 // Called after the frame's rect has changed; usually when the height is
    170 // animated.
    171 - (void)viewFrameDidChange:(NSNotification*)notification {
    172   // Anchor subviews at the top of |view|, so that it looks like the shelf
    173   // is sliding out.
    174   CGFloat newShelfHeight = NSHeight([[self view] frame]);
    175   if (newShelfHeight == currentShelfHeight_)
    176     return;
    177 
    178   for (NSView* view in [[self view] subviews]) {
    179     NSRect frame = [view frame];
    180     frame.origin.y -= currentShelfHeight_ - newShelfHeight;
    181     [view setFrame:frame];
    182   }
    183   currentShelfHeight_ = newShelfHeight;
    184 }
    185 
    186 // Adapt appearance to the current theme. Called after theme changes and before
    187 // this is shown for the first time.
    188 - (void)updateTheme {
    189   NSColor* color = nil;
    190 
    191   if (bridge_.get() && bridge_->browser() && bridge_->browser()->profile()) {
    192     ui::ThemeProvider* provider =
    193         ThemeServiceFactory::GetForProfile(bridge_->browser()->profile());
    194 
    195     color =
    196         provider->GetNSColor(ThemeService::COLOR_BOOKMARK_TEXT, false);
    197   }
    198 
    199   if (!color)
    200     color = [HyperlinkButtonCell defaultTextColor];
    201 
    202   [showAllDownloadsCell_ setTextColor:color];
    203 }
    204 
    205 - (AnimatableView*)animatableView {
    206   return static_cast<AnimatableView*>([self view]);
    207 }
    208 
    209 - (void)showDownloadsTab:(id)sender {
    210   bridge_->browser()->ShowDownloadsTab();
    211 }
    212 
    213 - (void)remove:(DownloadItemController*)download {
    214   // Look for the download in our controller array and remove it. This will
    215   // explicity release it so that it removes itself as an Observer of the
    216   // DownloadItem. We don't want to wait for autorelease since the DownloadItem
    217   // we are observing will likely be gone by then.
    218   [[NSNotificationCenter defaultCenter] removeObserver:download];
    219 
    220   // TODO(dmaclach): Remove -- http://crbug.com/25845
    221   [[download view] removeFromSuperview];
    222 
    223   [downloadItemControllers_ removeObject:download];
    224 
    225   [self layoutItems];
    226 
    227   // Check to see if we have any downloads remaining and if not, hide the shelf.
    228   if (![downloadItemControllers_ count])
    229     [self showDownloadShelf:NO];
    230 }
    231 
    232 - (void)downloadWasOpened:(DownloadItemController*)item_controller {
    233   // This should only be called on the main thead.
    234   DCHECK([NSThread isMainThread]);
    235 
    236   if ([self canAutoClose])
    237     [self installTrackingArea];
    238 }
    239 
    240 // We need to explicitly release our download controllers here since they need
    241 // to remove themselves as observers before the remaining shutdown happens.
    242 - (void)exiting {
    243   [[self animatableView] stopAnimation];
    244   [self cancelAutoCloseAndRemoveTrackingArea];
    245   downloadItemControllers_.reset();
    246 }
    247 
    248 // Show or hide the bar based on the value of |enable|. Handles animating the
    249 // resize of the content view.
    250 - (void)showDownloadShelf:(BOOL)enable {
    251   if ([self isVisible] == enable)
    252     return;
    253 
    254   if ([[self view] window])
    255     [self updateTheme];
    256 
    257   // Animate the shelf out, but not in.
    258   // TODO(rohitrao): We do not animate on the way in because Cocoa is already
    259   // doing a lot of work to set up the download arrow animation.  I've chosen to
    260   // do no animation over janky animation.  Find a way to make animating in
    261   // smoother.
    262   AnimatableView* view = [self animatableView];
    263   if (enable)
    264     [view setHeight:maxShelfHeight_];
    265   else
    266     [view animateToNewHeight:0 duration:kDownloadShelfCloseDuration];
    267 
    268   barIsVisible_ = enable;
    269   [self updateCloseButton];
    270 }
    271 
    272 - (DownloadShelf*)bridge {
    273   return bridge_.get();
    274 }
    275 
    276 - (BOOL)isVisible {
    277   return barIsVisible_;
    278 }
    279 
    280 - (void)show:(id)sender {
    281   [self showDownloadShelf:YES];
    282 }
    283 
    284 - (void)hide:(id)sender {
    285   [self cancelAutoCloseAndRemoveTrackingArea];
    286 
    287   // If |sender| isn't nil, then we're being closed from the UI by the user and
    288   // we need to tell our shelf implementation to close. Otherwise, we're being
    289   // closed programmatically by our shelf implementation.
    290   if (sender)
    291     bridge_->Close();
    292   else
    293     [self showDownloadShelf:NO];
    294 }
    295 
    296 - (void)animationDidEnd:(NSAnimation*)animation {
    297   if (![self isVisible])
    298     [self closed];
    299 }
    300 
    301 - (float)height {
    302   return maxShelfHeight_;
    303 }
    304 
    305 // If |skipFirst| is true, the frame of the leftmost item is not set.
    306 - (void)layoutItems:(BOOL)skipFirst {
    307   CGFloat currentX = 0;
    308   for (DownloadItemController* itemController
    309       in downloadItemControllers_.get()) {
    310     NSRect frame = [[itemController view] frame];
    311     frame.origin.x = currentX;
    312     frame.size.width = [itemController preferredSize].width;
    313     if (!skipFirst)
    314       [[[itemController view] animator] setFrame:frame];
    315     currentX += frame.size.width + kDownloadItemPadding;
    316     skipFirst = NO;
    317   }
    318 }
    319 
    320 - (void)layoutItems {
    321   [self layoutItems:NO];
    322 }
    323 
    324 - (void)addDownloadItem:(BaseDownloadItemModel*)model {
    325   DCHECK([NSThread isMainThread]);
    326   [self cancelAutoCloseAndRemoveTrackingArea];
    327 
    328   // Insert new item at the left.
    329   scoped_nsobject<DownloadItemController> controller(
    330       [[DownloadItemController alloc] initWithModel:model shelf:self]);
    331 
    332   // Adding at index 0 in NSMutableArrays is O(1).
    333   [downloadItemControllers_ insertObject:controller.get() atIndex:0];
    334 
    335   [itemContainerView_ addSubview:[controller view]];
    336 
    337   // The controller is in charge of removing itself as an observer in its
    338   // dealloc.
    339   [[NSNotificationCenter defaultCenter]
    340     addObserver:controller
    341        selector:@selector(updateVisibility:)
    342            name:NSViewFrameDidChangeNotification
    343          object:[controller view]];
    344   [[NSNotificationCenter defaultCenter]
    345     addObserver:controller
    346        selector:@selector(updateVisibility:)
    347            name:NSViewFrameDidChangeNotification
    348          object:itemContainerView_];
    349 
    350   // Start at width 0...
    351   NSSize size = [controller preferredSize];
    352   NSRect frame = NSMakeRect(0, 0, 0, size.height);
    353   [[controller view] setFrame:frame];
    354 
    355   // ...then animate in
    356   frame.size.width = size.width;
    357   [NSAnimationContext beginGrouping];
    358   [[NSAnimationContext currentContext]
    359       gtm_setDuration:kDownloadItemOpenDuration
    360             eventMask:NSLeftMouseUpMask];
    361   [[[controller view] animator] setFrame:frame];
    362   [NSAnimationContext endGrouping];
    363 
    364   // Keep only a limited number of items in the shelf.
    365   if ([downloadItemControllers_ count] > kMaxDownloadItemCount) {
    366     DCHECK(kMaxDownloadItemCount > 0);
    367 
    368     // Since no user will ever see the item being removed (needs a horizontal
    369     // screen resolution greater than 3200 at 16 items at 200 pixels each),
    370     // there's no point in animating the removal.
    371     [self remove:[downloadItemControllers_ lastObject]];
    372   }
    373 
    374   // Finally, move the remaining items to the right. Skip the first item when
    375   // laying out the items, so that the longer animation duration we set up above
    376   // is not overwritten.
    377   [self layoutItems:YES];
    378 }
    379 
    380 - (void)closed {
    381   NSUInteger i = 0;
    382   while (i < [downloadItemControllers_ count]) {
    383     DownloadItemController* itemController =
    384         [downloadItemControllers_ objectAtIndex:i];
    385     DownloadItem* download = [itemController download];
    386     bool isTransferDone = download->IsComplete() ||
    387                           download->IsCancelled() ||
    388                           download->IsInterrupted();
    389     if (isTransferDone &&
    390         download->safety_state() != DownloadItem::DANGEROUS) {
    391       [self remove:itemController];
    392     } else {
    393       // Treat the item as opened when we close. This way if we get shown again
    394       // the user need not open this item for the shelf to auto-close.
    395       download->set_opened(true);
    396       ++i;
    397     }
    398   }
    399 }
    400 
    401 - (void)mouseEntered:(NSEvent*)event {
    402   // If the mouse re-enters the download shelf, cancel the auto-close.  Further
    403   // mouse exits should not trigger autoclose, so also remove the tracking area.
    404   [self cancelAutoCloseAndRemoveTrackingArea];
    405 }
    406 
    407 - (void)mouseExited:(NSEvent*)event {
    408   // Cancel any previous hide requests, just to be safe.
    409   [NSObject cancelPreviousPerformRequestsWithTarget:self
    410                                            selector:@selector(hide:)
    411                                              object:self];
    412 
    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(hide:)
    416              withObject:self
    417              afterDelay:kAutoCloseDelaySeconds];
    418 }
    419 
    420 - (BOOL)canAutoClose {
    421   for (NSUInteger i = 0; i < [downloadItemControllers_ count]; ++i) {
    422     DownloadItemController* itemController =
    423         [downloadItemControllers_ objectAtIndex:i];
    424     if (![itemController download]->opened())
    425       return NO;
    426   }
    427   return YES;
    428 }
    429 
    430 - (void)installTrackingArea {
    431   // Install the tracking area to listen for mouseExited messages and trigger
    432   // the shelf autoclose.
    433   if (trackingArea_.get())
    434     return;
    435 
    436   trackingArea_.reset([[NSTrackingArea alloc]
    437                         initWithRect:[[self view] bounds]
    438                              options:NSTrackingMouseEnteredAndExited |
    439                                      NSTrackingActiveAlways
    440                                owner:self
    441                             userInfo:nil]);
    442   [[self view] addTrackingArea:trackingArea_];
    443 }
    444 
    445 - (void)cancelAutoCloseAndRemoveTrackingArea {
    446   [NSObject cancelPreviousPerformRequestsWithTarget:self
    447                                            selector:@selector(hide:)
    448                                              object:self];
    449 
    450   if (trackingArea_.get()) {
    451     [[self view] removeTrackingArea:trackingArea_];
    452     trackingArea_.reset(nil);
    453   }
    454 }
    455 
    456 - (void)willEnterFullscreen {
    457   isFullscreen_ = YES;
    458   [self updateCloseButton];
    459 }
    460 
    461 - (void)willLeaveFullscreen {
    462   isFullscreen_ = NO;
    463   [self updateCloseButton];
    464 }
    465 
    466 - (void)updateCloseButton {
    467   if (!barIsVisible_)
    468     return;
    469 
    470   NSRect selfBounds = [[self view] bounds];
    471   NSRect hoverFrame = [hoverCloseButton_ frame];
    472   NSRect bounds;
    473 
    474   if (isFullscreen_) {
    475     bounds = NSMakeRect(NSMinX(hoverFrame), 0,
    476                         selfBounds.size.width - NSMinX(hoverFrame),
    477                         selfBounds.size.height);
    478   } else {
    479     bounds.origin.x = NSMinX(hoverFrame);
    480     bounds.origin.y = NSMidY(hoverFrame) -
    481                       kHoverCloseButtonDefaultSize.height / 2.0;
    482     bounds.size = kHoverCloseButtonDefaultSize;
    483   }
    484 
    485   // Set the tracking off to create a new tracking area for the control.
    486   // When changing the bounds/frame on a HoverButton, the tracking isn't updated
    487   // correctly, it needs to be turned off and back on.
    488   [hoverCloseButton_ setTrackingEnabled:NO];
    489   [hoverCloseButton_ setFrame:bounds];
    490   [hoverCloseButton_ setTrackingEnabled:YES];
    491 }
    492 @end
    493