apps_grid_view_item.mm revision 010d83a9304c5a91596085d917d248abff47903a
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
23namespace {
24
25// Padding from the top of the tile to the top of the app icon.
26const CGFloat kTileTopPadding = 10;
27
28const CGFloat kIconSize = 48;
29
30const CGFloat kProgressBarHorizontalPadding = 8;
31const 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.
35const 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
65namespace app_list {
66
67class 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
89ItemModelObserverBridge::ItemModelObserverBridge(AppsGridViewItem* parent,
90                                       AppListItem* model)
91    : parent_(parent),
92      model_(model) {
93  model_->AddObserver(this);
94}
95
96ItemModelObserverBridge::~ItemModelObserverBridge() {
97  model_->RemoveObserver(this);
98}
99
100NSMenu* 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
113void ItemModelObserverBridge::ItemIconChanged() {
114  [parent_ updateButtonImage];
115}
116
117void ItemModelObserverBridge::ItemNameChanged() {
118  [parent_ updateButtonTitle];
119}
120
121void ItemModelObserverBridge::ItemHighlightedChanged() {
122  if (model_->highlighted())
123    [parent_ ensureVisible];
124}
125
126void ItemModelObserverBridge::ItemIsInstallingChanged() {
127  [parent_ setItemIsInstalling:model_->is_installing()];
128}
129
130void 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