1 // Copyright 2013 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 "ui/app_list/cocoa/apps_grid_view_item.h" 6 7 #include "base/mac/foundation_util.h" 8 #include "base/mac/mac_util.h" 9 #include "base/mac/scoped_nsobject.h" 10 #include "base/strings/sys_string_conversions.h" 11 #include "skia/ext/skia_utils_mac.h" 12 #include "ui/app_list/app_list_constants.h" 13 #include "ui/app_list/app_list_item.h" 14 #include "ui/app_list/app_list_item_observer.h" 15 #import "ui/app_list/cocoa/apps_grid_controller.h" 16 #import "ui/base/cocoa/menu_controller.h" 17 #include "ui/base/resource/resource_bundle.h" 18 #include "ui/gfx/font_list.h" 19 #include "ui/gfx/image/image_skia_operations.h" 20 #include "ui/gfx/image/image_skia_util_mac.h" 21 #include "ui/gfx/scoped_ns_graphics_context_save_gstate_mac.h" 22 23 namespace { 24 25 // Padding from the top of the tile to the top of the app icon. 26 const CGFloat kTileTopPadding = 10; 27 28 const CGFloat kIconSize = 48; 29 30 const CGFloat kProgressBarHorizontalPadding = 8; 31 const CGFloat kProgressBarVerticalPadding = 13; 32 33 // On Mac, fonts of the same enum from ResourceBundle are larger. The smallest 34 // enum is already used, so it needs to be reduced further to match Windows. 35 const int kMacFontSizeDelta = -1; 36 37 } // namespace 38 39 @class AppsGridItemBackgroundView; 40 41 @interface AppsGridViewItem () 42 43 // Typed accessor for the root view. 44 - (AppsGridItemBackgroundView*)itemBackgroundView; 45 46 // Bridged methods from app_list::AppListItemObserver: 47 // Update the title, correctly setting the color if the button is highlighted. 48 - (void)updateButtonTitle; 49 50 // Update the button image after ensuring its dimensions are |kIconSize|. 51 - (void)updateButtonImage; 52 53 // Ensure the page this item is on is the visible page in the grid. 54 - (void)ensureVisible; 55 56 // Add or remove a progress bar from the view. 57 - (void)setItemIsInstalling:(BOOL)isInstalling; 58 59 // Update the progress bar to represent |percent|, or make it indeterminate if 60 // |percent| is -1, when unpacking begins. 61 - (void)setPercentDownloaded:(int)percent; 62 63 @end 64 65 namespace app_list { 66 67 class ItemModelObserverBridge : public app_list::AppListItemObserver { 68 public: 69 ItemModelObserverBridge(AppsGridViewItem* parent, AppListItem* model); 70 virtual ~ItemModelObserverBridge(); 71 72 AppListItem* model() { return model_; } 73 NSMenu* GetContextMenu(); 74 75 virtual void ItemIconChanged() OVERRIDE; 76 virtual void ItemNameChanged() OVERRIDE; 77 virtual void ItemHighlightedChanged() OVERRIDE; 78 virtual void ItemIsInstallingChanged() OVERRIDE; 79 virtual void ItemPercentDownloadedChanged() OVERRIDE; 80 81 private: 82 AppsGridViewItem* parent_; // Weak. Owns us. 83 AppListItem* model_; // Weak. Owned by AppListModel. 84 base::scoped_nsobject<MenuController> context_menu_controller_; 85 86 DISALLOW_COPY_AND_ASSIGN(ItemModelObserverBridge); 87 }; 88 89 ItemModelObserverBridge::ItemModelObserverBridge(AppsGridViewItem* parent, 90 AppListItem* model) 91 : parent_(parent), 92 model_(model) { 93 model_->AddObserver(this); 94 } 95 96 ItemModelObserverBridge::~ItemModelObserverBridge() { 97 model_->RemoveObserver(this); 98 } 99 100 NSMenu* ItemModelObserverBridge::GetContextMenu() { 101 if (!context_menu_controller_) { 102 ui::MenuModel* menu_model = model_->GetContextMenuModel(); 103 if (!menu_model) 104 return nil; 105 106 context_menu_controller_.reset( 107 [[MenuController alloc] initWithModel:menu_model 108 useWithPopUpButtonCell:NO]); 109 } 110 return [context_menu_controller_ menu]; 111 } 112 113 void ItemModelObserverBridge::ItemIconChanged() { 114 [parent_ updateButtonImage]; 115 } 116 117 void ItemModelObserverBridge::ItemNameChanged() { 118 [parent_ updateButtonTitle]; 119 } 120 121 void ItemModelObserverBridge::ItemHighlightedChanged() { 122 if (model_->highlighted()) 123 [parent_ ensureVisible]; 124 } 125 126 void ItemModelObserverBridge::ItemIsInstallingChanged() { 127 [parent_ setItemIsInstalling:model_->is_installing()]; 128 } 129 130 void ItemModelObserverBridge::ItemPercentDownloadedChanged() { 131 [parent_ setPercentDownloaded:model_->percent_downloaded()]; 132 } 133 134 } // namespace app_list 135 136 // Container for an NSButton to allow proper alignment of the icon in the apps 137 // grid, and to draw with a highlight when selected. 138 @interface AppsGridItemBackgroundView : NSView { 139 @private 140 BOOL selected_; 141 } 142 143 - (NSButton*)button; 144 145 - (void)setSelected:(BOOL)flag; 146 147 @end 148 149 @interface AppsGridItemButtonCell : NSButtonCell { 150 @private 151 BOOL hasShadow_; 152 } 153 154 @property(assign, nonatomic) BOOL hasShadow; 155 156 @end 157 158 @interface AppsGridItemButton : NSButton; 159 @end 160 161 @implementation AppsGridItemBackgroundView 162 163 - (NSButton*)button { 164 // These views are part of a prototype NSCollectionViewItem, copied with an 165 // NSCoder. Rather than encoding additional members, the following relies on 166 // the button always being the first item added to AppsGridItemBackgroundView. 167 return base::mac::ObjCCastStrict<NSButton>([[self subviews] objectAtIndex:0]); 168 } 169 170 - (void)setSelected:(BOOL)flag { 171 DCHECK(selected_ != flag); 172 selected_ = flag; 173 [self setNeedsDisplay:YES]; 174 } 175 176 // Ignore all hit tests. The grid controller needs to be the owner of any drags. 177 - (NSView*)hitTest:(NSPoint)aPoint { 178 return nil; 179 } 180 181 - (void)drawRect:(NSRect)dirtyRect { 182 if (!selected_) 183 return; 184 185 [gfx::SkColorToSRGBNSColor(app_list::kSelectedColor) set]; 186 NSRectFillUsingOperation(dirtyRect, NSCompositeSourceOver); 187 } 188 189 - (void)mouseDown:(NSEvent*)theEvent { 190 [[[self button] cell] setHighlighted:YES]; 191 } 192 193 - (void)mouseDragged:(NSEvent*)theEvent { 194 NSPoint pointInView = [self convertPoint:[theEvent locationInWindow] 195 fromView:nil]; 196 BOOL isInView = [self mouse:pointInView inRect:[self bounds]]; 197 [[[self button] cell] setHighlighted:isInView]; 198 } 199 200 - (void)mouseUp:(NSEvent*)theEvent { 201 NSPoint pointInView = [self convertPoint:[theEvent locationInWindow] 202 fromView:nil]; 203 if (![self mouse:pointInView inRect:[self bounds]]) 204 return; 205 206 [[self button] performClick:self]; 207 } 208 209 @end 210 211 @implementation AppsGridViewItem 212 213 - (id)initWithSize:(NSSize)tileSize { 214 if ((self = [super init])) { 215 base::scoped_nsobject<AppsGridItemButton> prototypeButton( 216 [[AppsGridItemButton alloc] initWithFrame:NSMakeRect( 217 0, 0, tileSize.width, tileSize.height - kTileTopPadding)]); 218 219 // This NSButton style always positions the icon at the very top of the 220 // button frame. AppsGridViewItem uses an enclosing view so that it is 221 // visually correct. 222 [prototypeButton setImagePosition:NSImageAbove]; 223 [prototypeButton setButtonType:NSMomentaryChangeButton]; 224 [prototypeButton setBordered:NO]; 225 226 base::scoped_nsobject<AppsGridItemBackgroundView> prototypeButtonBackground( 227 [[AppsGridItemBackgroundView alloc] 228 initWithFrame:NSMakeRect(0, 0, tileSize.width, tileSize.height)]); 229 [prototypeButtonBackground addSubview:prototypeButton]; 230 [self setView:prototypeButtonBackground]; 231 } 232 return self; 233 } 234 235 - (NSProgressIndicator*)progressIndicator { 236 return progressIndicator_; 237 } 238 239 - (void)updateButtonTitle { 240 if (progressIndicator_) 241 return; 242 243 base::scoped_nsobject<NSMutableParagraphStyle> paragraphStyle( 244 [[NSMutableParagraphStyle alloc] init]); 245 [paragraphStyle setLineBreakMode:NSLineBreakByTruncatingTail]; 246 [paragraphStyle setAlignment:NSCenterTextAlignment]; 247 NSDictionary* titleAttributes = @{ 248 NSParagraphStyleAttributeName : paragraphStyle, 249 NSFontAttributeName : ui::ResourceBundle::GetSharedInstance() 250 .GetFontList(app_list::kItemTextFontStyle) 251 .DeriveWithSizeDelta(kMacFontSizeDelta) 252 .GetPrimaryFont() 253 .GetNativeFont(), 254 NSForegroundColorAttributeName : [self isSelected] ? 255 gfx::SkColorToSRGBNSColor(app_list::kGridTitleHoverColor) : 256 gfx::SkColorToSRGBNSColor(app_list::kGridTitleColor) 257 }; 258 NSString* buttonTitle = 259 base::SysUTF8ToNSString([self model]->GetDisplayName()); 260 base::scoped_nsobject<NSAttributedString> attributedTitle( 261 [[NSAttributedString alloc] initWithString:buttonTitle 262 attributes:titleAttributes]); 263 [[self button] setAttributedTitle:attributedTitle]; 264 265 // If the display name would be truncated in the NSButton, or if the display 266 // name differs from the full name, add a tooltip showing the full name. 267 NSRect titleRect = 268 [[[self button] cell] titleRectForBounds:[[self button] bounds]]; 269 if ([self model]->name() == [self model]->GetDisplayName() && 270 [attributedTitle size].width < NSWidth(titleRect)) { 271 [[self view] removeAllToolTips]; 272 } else { 273 [[self view] setToolTip:base::SysUTF8ToNSString([self model]->name())]; 274 } 275 } 276 277 - (void)updateButtonImage { 278 const gfx::Size iconSize = gfx::Size(kIconSize, kIconSize); 279 gfx::ImageSkia icon = [self model]->icon(); 280 if (icon.size() != iconSize) { 281 icon = gfx::ImageSkiaOperations::CreateResizedImage( 282 icon, skia::ImageOperations::RESIZE_BEST, iconSize); 283 } 284 NSImage* buttonImage = gfx::NSImageFromImageSkiaWithColorSpace( 285 icon, base::mac::GetSRGBColorSpace()); 286 [[self button] setImage:buttonImage]; 287 [[[self button] cell] setHasShadow:[self model]->has_shadow()]; 288 } 289 290 - (void)setModel:(app_list::AppListItem*)itemModel { 291 [trackingArea_.get() clearOwner]; 292 if (!itemModel) { 293 observerBridge_.reset(); 294 return; 295 } 296 297 observerBridge_.reset(new app_list::ItemModelObserverBridge(self, itemModel)); 298 [self updateButtonTitle]; 299 [self updateButtonImage]; 300 301 if (trackingArea_.get()) 302 [[self view] removeTrackingArea:trackingArea_.get()]; 303 304 trackingArea_.reset( 305 [[CrTrackingArea alloc] initWithRect:NSZeroRect 306 options:NSTrackingInVisibleRect | 307 NSTrackingMouseEnteredAndExited | 308 NSTrackingActiveInKeyWindow 309 owner:self 310 userInfo:nil]); 311 [[self view] addTrackingArea:trackingArea_.get()]; 312 } 313 314 - (app_list::AppListItem*)model { 315 return observerBridge_->model(); 316 } 317 318 - (NSButton*)button { 319 return [[self itemBackgroundView] button]; 320 } 321 322 - (NSMenu*)contextMenu { 323 // Don't show the menu if button is already held down, e.g. with a left-click. 324 if ([[[self button] cell] isHighlighted]) 325 return nil; 326 327 [self setSelected:YES]; 328 return observerBridge_->GetContextMenu(); 329 } 330 331 - (NSBitmapImageRep*)dragRepresentationForRestore:(BOOL)isRestore { 332 NSButton* button = [self button]; 333 NSView* itemView = [self view]; 334 335 // The snapshot is never drawn as if it was selected. Also remove the cell 336 // highlight on the button image, added when it was clicked. 337 [button setHidden:NO]; 338 [[button cell] setHighlighted:NO]; 339 [self setSelected:NO]; 340 [progressIndicator_ setHidden:YES]; 341 if (isRestore) 342 [self updateButtonTitle]; 343 else 344 [button setTitle:@""]; 345 346 NSBitmapImageRep* imageRep = 347 [itemView bitmapImageRepForCachingDisplayInRect:[itemView visibleRect]]; 348 [itemView cacheDisplayInRect:[itemView visibleRect] 349 toBitmapImageRep:imageRep]; 350 351 if (isRestore) { 352 [progressIndicator_ setHidden:NO]; 353 [self setSelected:YES]; 354 } 355 // Button is always hidden until the drag animation completes. 356 [button setHidden:YES]; 357 return imageRep; 358 } 359 360 - (void)ensureVisible { 361 NSCollectionView* collectionView = [self collectionView]; 362 AppsGridController* gridController = 363 base::mac::ObjCCastStrict<AppsGridController>([collectionView delegate]); 364 size_t pageIndex = [gridController pageIndexForCollectionView:collectionView]; 365 [gridController scrollToPage:pageIndex]; 366 } 367 368 - (void)setItemIsInstalling:(BOOL)isInstalling { 369 if (!isInstalling == !progressIndicator_) 370 return; 371 372 [self ensureVisible]; 373 if (!isInstalling) { 374 [progressIndicator_ removeFromSuperview]; 375 progressIndicator_.reset(); 376 [self updateButtonTitle]; 377 [self setSelected:YES]; 378 return; 379 } 380 381 NSRect rect = NSMakeRect( 382 kProgressBarHorizontalPadding, 383 kProgressBarVerticalPadding, 384 NSWidth([[self view] bounds]) - 2 * kProgressBarHorizontalPadding, 385 NSProgressIndicatorPreferredAquaThickness); 386 [[self button] setTitle:@""]; 387 progressIndicator_.reset([[NSProgressIndicator alloc] initWithFrame:rect]); 388 [progressIndicator_ setIndeterminate:NO]; 389 [progressIndicator_ setControlSize:NSSmallControlSize]; 390 [[self view] addSubview:progressIndicator_]; 391 } 392 393 - (void)setPercentDownloaded:(int)percent { 394 // In a corner case, items can be installing when they are first added. For 395 // those, the icon will start desaturated. Wait for a progress update before 396 // showing the progress bar. 397 [self setItemIsInstalling:YES]; 398 if (percent != -1) { 399 [progressIndicator_ setDoubleValue:percent]; 400 return; 401 } 402 403 // Otherwise, fully downloaded and waiting for install to complete. 404 [progressIndicator_ setIndeterminate:YES]; 405 [progressIndicator_ startAnimation:self]; 406 } 407 408 - (AppsGridItemBackgroundView*)itemBackgroundView { 409 return base::mac::ObjCCastStrict<AppsGridItemBackgroundView>([self view]); 410 } 411 412 - (void)mouseEntered:(NSEvent*)theEvent { 413 [self setSelected:YES]; 414 } 415 416 - (void)mouseExited:(NSEvent*)theEvent { 417 [self setSelected:NO]; 418 } 419 420 - (void)setSelected:(BOOL)flag { 421 if ([self isSelected] == flag) 422 return; 423 424 [[self itemBackgroundView] setSelected:flag]; 425 [super setSelected:flag]; 426 [self updateButtonTitle]; 427 } 428 429 @end 430 431 @implementation AppsGridItemButton 432 433 + (Class)cellClass { 434 return [AppsGridItemButtonCell class]; 435 } 436 437 @end 438 439 @implementation AppsGridItemButtonCell 440 441 @synthesize hasShadow = hasShadow_; 442 443 - (void)drawImage:(NSImage*)image 444 withFrame:(NSRect)frame 445 inView:(NSView*)controlView { 446 if (!hasShadow_) { 447 [super drawImage:image 448 withFrame:frame 449 inView:controlView]; 450 return; 451 } 452 453 base::scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]); 454 gfx::ScopedNSGraphicsContextSaveGState context; 455 [shadow setShadowOffset:NSMakeSize(0, -2)]; 456 [shadow setShadowBlurRadius:2.0]; 457 [shadow setShadowColor:[NSColor colorWithCalibratedWhite:0 458 alpha:0.14]]; 459 [shadow set]; 460 461 [super drawImage:image 462 withFrame:frame 463 inView:controlView]; 464 } 465 466 // Workaround for http://crbug.com/324365: AppKit in Mavericks tries to call 467 // - [NSButtonCell item] when inspecting accessibility. Without this, an 468 // unrecognized selector exception is thrown inside AppKit, crashing Chrome. 469 - (id)item { 470 return nil; 471 } 472 473 @end 474