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_model.h" 14 #include "ui/app_list/app_list_item_model_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.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::AppListItemModelObserver: 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::AppListItemModelObserver { 68 public: 69 ItemModelObserverBridge(AppsGridViewItem* parent, AppListItemModel* model); 70 virtual ~ItemModelObserverBridge(); 71 72 AppListItemModel* model() { return model_; } 73 NSMenu* GetContextMenu(); 74 75 virtual void ItemIconChanged() OVERRIDE; 76 virtual void ItemTitleChanged() 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 AppListItemModel* model_; // Weak. Owned by AppListModel::Apps. 84 base::scoped_nsobject<MenuController> context_menu_controller_; 85 86 DISALLOW_COPY_AND_ASSIGN(ItemModelObserverBridge); 87 }; 88 89 ItemModelObserverBridge::ItemModelObserverBridge(AppsGridViewItem* parent, 90 AppListItemModel* 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 context_menu_controller_.reset( 103 [[MenuController alloc] initWithModel:model_->GetContextMenuModel() 104 useWithPopUpButtonCell:NO]); 105 } 106 return [context_menu_controller_ menu]; 107 } 108 109 void ItemModelObserverBridge::ItemIconChanged() { 110 [parent_ updateButtonImage]; 111 } 112 113 void ItemModelObserverBridge::ItemTitleChanged() { 114 [parent_ updateButtonTitle]; 115 } 116 117 void ItemModelObserverBridge::ItemHighlightedChanged() { 118 if (model_->highlighted()) 119 [parent_ ensureVisible]; 120 } 121 122 void ItemModelObserverBridge::ItemIsInstallingChanged() { 123 [parent_ setItemIsInstalling:model_->is_installing()]; 124 } 125 126 void ItemModelObserverBridge::ItemPercentDownloadedChanged() { 127 [parent_ setPercentDownloaded:model_->percent_downloaded()]; 128 } 129 130 } // namespace app_list 131 132 // Container for an NSButton to allow proper alignment of the icon in the apps 133 // grid, and to draw with a highlight when selected. 134 @interface AppsGridItemBackgroundView : NSView { 135 @private 136 BOOL selected_; 137 } 138 139 - (NSButton*)button; 140 141 - (void)setSelected:(BOOL)flag; 142 143 @end 144 145 @interface AppsGridItemButtonCell : NSButtonCell { 146 @private 147 BOOL hasShadow_; 148 } 149 150 @property(assign, nonatomic) BOOL hasShadow; 151 152 @end 153 154 @interface AppsGridItemButton : NSButton; 155 @end 156 157 @implementation AppsGridItemBackgroundView 158 159 - (NSButton*)button { 160 // These views are part of a prototype NSCollectionViewItem, copied with an 161 // NSCoder. Rather than encoding additional members, the following relies on 162 // the button always being the first item added to AppsGridItemBackgroundView. 163 return base::mac::ObjCCastStrict<NSButton>([[self subviews] objectAtIndex:0]); 164 } 165 166 - (void)setSelected:(BOOL)flag { 167 DCHECK(selected_ != flag); 168 selected_ = flag; 169 [self setNeedsDisplay:YES]; 170 } 171 172 // Ignore all hit tests. The grid controller needs to be the owner of any drags. 173 - (NSView*)hitTest:(NSPoint)aPoint { 174 return nil; 175 } 176 177 - (void)drawRect:(NSRect)dirtyRect { 178 if (!selected_) 179 return; 180 181 [gfx::SkColorToSRGBNSColor(app_list::kSelectedColor) set]; 182 NSRectFillUsingOperation(dirtyRect, NSCompositeSourceOver); 183 } 184 185 - (void)mouseDown:(NSEvent*)theEvent { 186 [[[self button] cell] setHighlighted:YES]; 187 } 188 189 - (void)mouseDragged:(NSEvent*)theEvent { 190 NSPoint pointInView = [self convertPoint:[theEvent locationInWindow] 191 fromView:nil]; 192 BOOL isInView = [self mouse:pointInView inRect:[self bounds]]; 193 [[[self button] cell] setHighlighted:isInView]; 194 } 195 196 - (void)mouseUp:(NSEvent*)theEvent { 197 NSPoint pointInView = [self convertPoint:[theEvent locationInWindow] 198 fromView:nil]; 199 if (![self mouse:pointInView inRect:[self bounds]]) 200 return; 201 202 [[self button] performClick:self]; 203 } 204 205 @end 206 207 @implementation AppsGridViewItem 208 209 - (id)initWithSize:(NSSize)tileSize { 210 if ((self = [super init])) { 211 base::scoped_nsobject<AppsGridItemButton> prototypeButton( 212 [[AppsGridItemButton alloc] initWithFrame:NSMakeRect( 213 0, 0, tileSize.width, tileSize.height - kTileTopPadding)]); 214 215 // This NSButton style always positions the icon at the very top of the 216 // button frame. AppsGridViewItem uses an enclosing view so that it is 217 // visually correct. 218 [prototypeButton setImagePosition:NSImageAbove]; 219 [prototypeButton setButtonType:NSMomentaryChangeButton]; 220 [prototypeButton setBordered:NO]; 221 222 base::scoped_nsobject<AppsGridItemBackgroundView> prototypeButtonBackground( 223 [[AppsGridItemBackgroundView alloc] 224 initWithFrame:NSMakeRect(0, 0, tileSize.width, tileSize.height)]); 225 [prototypeButtonBackground addSubview:prototypeButton]; 226 [self setView:prototypeButtonBackground]; 227 } 228 return self; 229 } 230 231 - (NSProgressIndicator*)progressIndicator { 232 return progressIndicator_; 233 } 234 235 - (void)updateButtonTitle { 236 if (progressIndicator_) 237 return; 238 239 base::scoped_nsobject<NSMutableParagraphStyle> paragraphStyle( 240 [[NSMutableParagraphStyle alloc] init]); 241 [paragraphStyle setLineBreakMode:NSLineBreakByTruncatingTail]; 242 [paragraphStyle setAlignment:NSCenterTextAlignment]; 243 NSDictionary* titleAttributes = @{ 244 NSParagraphStyleAttributeName : paragraphStyle, 245 NSFontAttributeName : ui::ResourceBundle::GetSharedInstance() 246 .GetFont(app_list::kItemTextFontStyle) 247 .DeriveFont(kMacFontSizeDelta) 248 .GetNativeFont(), 249 NSForegroundColorAttributeName : [self isSelected] ? 250 gfx::SkColorToSRGBNSColor(app_list::kGridTitleHoverColor) : 251 gfx::SkColorToSRGBNSColor(app_list::kGridTitleColor) 252 }; 253 NSString* buttonTitle = base::SysUTF8ToNSString([self model]->title()); 254 base::scoped_nsobject<NSAttributedString> attributedTitle( 255 [[NSAttributedString alloc] initWithString:buttonTitle 256 attributes:titleAttributes]); 257 [[self button] setAttributedTitle:attributedTitle]; 258 } 259 260 - (void)updateButtonImage { 261 const gfx::Size iconSize = gfx::Size(kIconSize, kIconSize); 262 gfx::ImageSkia icon = [self model]->icon(); 263 if (icon.size() != iconSize) { 264 icon = gfx::ImageSkiaOperations::CreateResizedImage( 265 icon, skia::ImageOperations::RESIZE_BEST, iconSize); 266 } 267 NSImage* buttonImage = gfx::NSImageFromImageSkiaWithColorSpace( 268 icon, base::mac::GetSRGBColorSpace()); 269 [[self button] setImage:buttonImage]; 270 [[[self button] cell] setHasShadow:[self model]->has_shadow()]; 271 } 272 273 - (void)setModel:(app_list::AppListItemModel*)itemModel { 274 if (!itemModel) { 275 observerBridge_.reset(); 276 return; 277 } 278 279 observerBridge_.reset(new app_list::ItemModelObserverBridge(self, itemModel)); 280 [self updateButtonTitle]; 281 [self updateButtonImage]; 282 283 if (trackingArea_.get()) 284 [[self view] removeTrackingArea:trackingArea_.get()]; 285 286 trackingArea_.reset( 287 [[CrTrackingArea alloc] initWithRect:NSZeroRect 288 options:NSTrackingInVisibleRect | 289 NSTrackingMouseEnteredAndExited | 290 NSTrackingActiveInKeyWindow 291 owner:self 292 userInfo:nil]); 293 [[self view] addTrackingArea:trackingArea_.get()]; 294 } 295 296 - (app_list::AppListItemModel*)model { 297 return observerBridge_->model(); 298 } 299 300 - (NSButton*)button { 301 return [[self itemBackgroundView] button]; 302 } 303 304 - (NSMenu*)contextMenu { 305 [self setSelected:YES]; 306 return observerBridge_->GetContextMenu(); 307 } 308 309 - (NSBitmapImageRep*)dragRepresentationForRestore:(BOOL)isRestore { 310 NSButton* button = [self button]; 311 NSView* itemView = [self view]; 312 313 // The snapshot is never drawn as if it was selected. Also remove the cell 314 // highlight on the button image, added when it was clicked. 315 [button setHidden:NO]; 316 [[button cell] setHighlighted:NO]; 317 [self setSelected:NO]; 318 [progressIndicator_ setHidden:YES]; 319 if (isRestore) 320 [self updateButtonTitle]; 321 else 322 [button setTitle:@""]; 323 324 NSBitmapImageRep* imageRep = 325 [itemView bitmapImageRepForCachingDisplayInRect:[itemView visibleRect]]; 326 [itemView cacheDisplayInRect:[itemView visibleRect] 327 toBitmapImageRep:imageRep]; 328 329 if (isRestore) { 330 [progressIndicator_ setHidden:NO]; 331 [self setSelected:YES]; 332 } 333 // Button is always hidden until the drag animation completes. 334 [button setHidden:YES]; 335 return imageRep; 336 } 337 338 - (void)onInitialModelBuilt { 339 if ([self model]->highlighted()) { 340 [self ensureVisible]; 341 if (![self model]->is_installing()) 342 [self setSelected:YES]; 343 } 344 } 345 346 - (void)ensureVisible { 347 NSCollectionView* collectionView = [self collectionView]; 348 AppsGridController* gridController = 349 base::mac::ObjCCastStrict<AppsGridController>([collectionView delegate]); 350 size_t pageIndex = [gridController pageIndexForCollectionView:collectionView]; 351 [gridController scrollToPage:pageIndex]; 352 } 353 354 - (void)setItemIsInstalling:(BOOL)isInstalling { 355 if (!isInstalling == !progressIndicator_) 356 return; 357 358 [self ensureVisible]; 359 if (!isInstalling) { 360 [progressIndicator_ removeFromSuperview]; 361 progressIndicator_.reset(); 362 [self updateButtonTitle]; 363 [self setSelected:YES]; 364 return; 365 } 366 367 NSRect rect = NSMakeRect( 368 kProgressBarHorizontalPadding, 369 kProgressBarVerticalPadding, 370 NSWidth([[self view] bounds]) - 2 * kProgressBarHorizontalPadding, 371 NSProgressIndicatorPreferredAquaThickness); 372 [[self button] setTitle:@""]; 373 progressIndicator_.reset([[NSProgressIndicator alloc] initWithFrame:rect]); 374 [progressIndicator_ setIndeterminate:NO]; 375 [progressIndicator_ setControlSize:NSSmallControlSize]; 376 [[self view] addSubview:progressIndicator_]; 377 } 378 379 - (void)setPercentDownloaded:(int)percent { 380 // In a corner case, items can be installing when they are first added. For 381 // those, the icon will start desaturated. Wait for a progress update before 382 // showing the progress bar. 383 [self setItemIsInstalling:YES]; 384 if (percent != -1) { 385 [progressIndicator_ setDoubleValue:percent]; 386 return; 387 } 388 389 // Otherwise, fully downloaded and waiting for install to complete. 390 [progressIndicator_ setIndeterminate:YES]; 391 [progressIndicator_ startAnimation:self]; 392 } 393 394 - (AppsGridItemBackgroundView*)itemBackgroundView { 395 return base::mac::ObjCCastStrict<AppsGridItemBackgroundView>([self view]); 396 } 397 398 - (void)mouseEntered:(NSEvent*)theEvent { 399 [self setSelected:YES]; 400 } 401 402 - (void)mouseExited:(NSEvent*)theEvent { 403 [self setSelected:NO]; 404 } 405 406 - (void)setSelected:(BOOL)flag { 407 if ([self isSelected] == flag) 408 return; 409 410 [[self itemBackgroundView] setSelected:flag]; 411 [super setSelected:flag]; 412 [self updateButtonTitle]; 413 } 414 415 @end 416 417 @implementation AppsGridItemButton 418 419 + (Class)cellClass { 420 return [AppsGridItemButtonCell class]; 421 } 422 423 @end 424 425 @implementation AppsGridItemButtonCell 426 427 @synthesize hasShadow = hasShadow_; 428 429 - (void)drawImage:(NSImage*)image 430 withFrame:(NSRect)frame 431 inView:(NSView*)controlView { 432 if (!hasShadow_) { 433 [super drawImage:image 434 withFrame:frame 435 inView:controlView]; 436 return; 437 } 438 439 base::scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]); 440 gfx::ScopedNSGraphicsContextSaveGState context; 441 [shadow setShadowOffset:NSMakeSize(0, -2)]; 442 [shadow setShadowBlurRadius:2.0]; 443 [shadow setShadowColor:[NSColor colorWithCalibratedWhite:0 444 alpha:0.14]]; 445 [shadow set]; 446 447 [super drawImage:image 448 withFrame:frame 449 inView:controlView]; 450 } 451 452 @end 453