Home | History | Annotate | Download | only in cocoa
      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