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