• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • 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
49namespace {
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.
53const size_t kMaxDownloadItemCount = 16;
54
55// Horizontal padding between two download items.
56const int kDownloadItemPadding = 0;
57
58// Duration for the open-new-leftmost-item animation, in seconds.
59const NSTimeInterval kDownloadItemOpenDuration = 0.8;
60
61// Duration for download shelf closing animation, in seconds.
62const 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.
66const NSTimeInterval kAutoCloseDelaySeconds = 5;
67
68// The size of the x button by default.
69const 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