apps_grid_view_item.mm revision 5c02ac1a9c1b504631c0a3d2b6e737b5d738bae1
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  // Whether the item is selected, and draws a background.
141  BOOL selected_;
142
143  // Whether to intercept the next call to -[NSView setFrame:] and override it.
144  BOOL overrideNextSetFrame_;
145
146  // The frame given to -[super setFrame:], when |overrideNextSetFrame_| is set.
147  NSRect overrideFrame_;
148}
149
150- (NSButton*)button;
151
152- (void)setSelected:(BOOL)flag;
153
154- (void)setOneshotFrameRect:(NSRect)frameRect;
155
156@end
157
158@interface AppsGridItemButtonCell : NSButtonCell {
159 @private
160  BOOL hasShadow_;
161}
162
163@property(assign, nonatomic) BOOL hasShadow;
164
165@end
166
167@interface AppsGridItemButton : NSButton;
168@end
169
170@implementation AppsGridItemBackgroundView
171
172- (NSButton*)button {
173  // These views are part of a prototype NSCollectionViewItem, copied with an
174  // NSCoder. Rather than encoding additional members, the following relies on
175  // the button always being the first item added to AppsGridItemBackgroundView.
176  return base::mac::ObjCCastStrict<NSButton>([[self subviews] objectAtIndex:0]);
177}
178
179- (void)setSelected:(BOOL)flag {
180  DCHECK(selected_ != flag);
181  selected_ = flag;
182  [self setNeedsDisplay:YES];
183}
184
185- (void)setOneshotFrameRect:(NSRect)frameRect {
186  [super setFrame:frameRect];
187  overrideNextSetFrame_ = YES;
188  overrideFrame_ = frameRect;
189}
190
191- (void)setFrame:(NSRect)frameRect {
192  if (overrideNextSetFrame_) {
193    frameRect = overrideFrame_;
194    overrideNextSetFrame_ = NO;
195  }
196  [super setFrame:frameRect];
197}
198
199// Ignore all hit tests. The grid controller needs to be the owner of any drags.
200- (NSView*)hitTest:(NSPoint)aPoint {
201  return nil;
202}
203
204- (void)drawRect:(NSRect)dirtyRect {
205  if (!selected_)
206    return;
207
208  [gfx::SkColorToSRGBNSColor(app_list::kSelectedColor) set];
209  NSRectFillUsingOperation(dirtyRect, NSCompositeSourceOver);
210}
211
212- (void)mouseDown:(NSEvent*)theEvent {
213  [[[self button] cell] setHighlighted:YES];
214}
215
216- (void)mouseDragged:(NSEvent*)theEvent {
217  NSPoint pointInView = [self convertPoint:[theEvent locationInWindow]
218                                  fromView:nil];
219  BOOL isInView = [self mouse:pointInView inRect:[self bounds]];
220  [[[self button] cell] setHighlighted:isInView];
221}
222
223- (void)mouseUp:(NSEvent*)theEvent {
224  NSPoint pointInView = [self convertPoint:[theEvent locationInWindow]
225                                  fromView:nil];
226  if (![self mouse:pointInView inRect:[self bounds]])
227    return;
228
229  [[self button] performClick:self];
230}
231
232@end
233
234@implementation AppsGridViewItem
235
236- (id)initWithSize:(NSSize)tileSize {
237  if ((self = [super init])) {
238    base::scoped_nsobject<AppsGridItemButton> prototypeButton(
239        [[AppsGridItemButton alloc] initWithFrame:NSMakeRect(
240            0, 0, tileSize.width, tileSize.height - kTileTopPadding)]);
241
242    // This NSButton style always positions the icon at the very top of the
243    // button frame. AppsGridViewItem uses an enclosing view so that it is
244    // visually correct.
245    [prototypeButton setImagePosition:NSImageAbove];
246    [prototypeButton setButtonType:NSMomentaryChangeButton];
247    [prototypeButton setBordered:NO];
248
249    base::scoped_nsobject<AppsGridItemBackgroundView> prototypeButtonBackground(
250        [[AppsGridItemBackgroundView alloc]
251            initWithFrame:NSMakeRect(0, 0, tileSize.width, tileSize.height)]);
252    [prototypeButtonBackground addSubview:prototypeButton];
253    [self setView:prototypeButtonBackground];
254  }
255  return self;
256}
257
258- (NSProgressIndicator*)progressIndicator {
259  return progressIndicator_;
260}
261
262- (void)updateButtonTitle {
263  if (progressIndicator_)
264    return;
265
266  base::scoped_nsobject<NSMutableParagraphStyle> paragraphStyle(
267      [[NSMutableParagraphStyle alloc] init]);
268  [paragraphStyle setLineBreakMode:NSLineBreakByTruncatingTail];
269  [paragraphStyle setAlignment:NSCenterTextAlignment];
270  NSDictionary* titleAttributes = @{
271    NSParagraphStyleAttributeName : paragraphStyle,
272    NSFontAttributeName : ui::ResourceBundle::GetSharedInstance()
273        .GetFontList(app_list::kItemTextFontStyle)
274        .DeriveWithSizeDelta(kMacFontSizeDelta)
275        .GetPrimaryFont()
276        .GetNativeFont(),
277    NSForegroundColorAttributeName : [self isSelected] ?
278        gfx::SkColorToSRGBNSColor(app_list::kGridTitleHoverColor) :
279        gfx::SkColorToSRGBNSColor(app_list::kGridTitleColor)
280  };
281  NSString* buttonTitle =
282      base::SysUTF8ToNSString([self model]->GetDisplayName());
283  base::scoped_nsobject<NSAttributedString> attributedTitle(
284      [[NSAttributedString alloc] initWithString:buttonTitle
285                                      attributes:titleAttributes]);
286  [[self button] setAttributedTitle:attributedTitle];
287
288  // If the display name would be truncated in the NSButton, or if the display
289  // name differs from the full name, add a tooltip showing the full name.
290  NSRect titleRect =
291      [[[self button] cell] titleRectForBounds:[[self button] bounds]];
292  if ([self model]->name() == [self model]->GetDisplayName() &&
293      [attributedTitle size].width < NSWidth(titleRect)) {
294    [[self view] removeAllToolTips];
295  } else {
296    [[self view] setToolTip:base::SysUTF8ToNSString([self model]->name())];
297  }
298}
299
300- (void)updateButtonImage {
301  const gfx::Size iconSize = gfx::Size(kIconSize, kIconSize);
302  gfx::ImageSkia icon = [self model]->icon();
303  if (icon.size() != iconSize) {
304    icon = gfx::ImageSkiaOperations::CreateResizedImage(
305        icon, skia::ImageOperations::RESIZE_BEST, iconSize);
306  }
307  NSImage* buttonImage = gfx::NSImageFromImageSkiaWithColorSpace(
308      icon, base::mac::GetSRGBColorSpace());
309  [[self button] setImage:buttonImage];
310  [[[self button] cell] setHasShadow:[self model]->has_shadow()];
311}
312
313- (void)setModel:(app_list::AppListItem*)itemModel {
314  [trackingArea_.get() clearOwner];
315  if (!itemModel) {
316    observerBridge_.reset();
317    return;
318  }
319
320  observerBridge_.reset(new app_list::ItemModelObserverBridge(self, itemModel));
321  [self updateButtonTitle];
322  [self updateButtonImage];
323
324  if (trackingArea_.get())
325    [[self view] removeTrackingArea:trackingArea_.get()];
326
327  trackingArea_.reset(
328      [[CrTrackingArea alloc] initWithRect:NSZeroRect
329                                   options:NSTrackingInVisibleRect |
330                                           NSTrackingMouseEnteredAndExited |
331                                           NSTrackingActiveInKeyWindow
332                                     owner:self
333                                  userInfo:nil]);
334  [[self view] addTrackingArea:trackingArea_.get()];
335}
336
337- (void)setInitialFrameRect:(NSRect)frameRect {
338  [[self itemBackgroundView] setOneshotFrameRect:frameRect];
339}
340
341- (app_list::AppListItem*)model {
342  return observerBridge_->model();
343}
344
345- (NSButton*)button {
346  return [[self itemBackgroundView] button];
347}
348
349- (NSMenu*)contextMenu {
350  // Don't show the menu if button is already held down, e.g. with a left-click.
351  if ([[[self button] cell] isHighlighted])
352    return nil;
353
354  [self setSelected:YES];
355  return observerBridge_->GetContextMenu();
356}
357
358- (NSBitmapImageRep*)dragRepresentationForRestore:(BOOL)isRestore {
359  NSButton* button = [self button];
360  NSView* itemView = [self view];
361
362  // The snapshot is never drawn as if it was selected. Also remove the cell
363  // highlight on the button image, added when it was clicked.
364  [button setHidden:NO];
365  [[button cell] setHighlighted:NO];
366  [self setSelected:NO];
367  [progressIndicator_ setHidden:YES];
368  if (isRestore)
369    [self updateButtonTitle];
370  else
371    [button setTitle:@""];
372
373  NSBitmapImageRep* imageRep =
374      [itemView bitmapImageRepForCachingDisplayInRect:[itemView visibleRect]];
375  [itemView cacheDisplayInRect:[itemView visibleRect]
376              toBitmapImageRep:imageRep];
377
378  if (isRestore) {
379    [progressIndicator_ setHidden:NO];
380    [self setSelected:YES];
381  }
382  // Button is always hidden until the drag animation completes.
383  [button setHidden:YES];
384  return imageRep;
385}
386
387- (void)ensureVisible {
388  NSCollectionView* collectionView = [self collectionView];
389  AppsGridController* gridController =
390      base::mac::ObjCCastStrict<AppsGridController>([collectionView delegate]);
391  size_t pageIndex = [gridController pageIndexForCollectionView:collectionView];
392  [gridController scrollToPage:pageIndex];
393}
394
395- (void)setItemIsInstalling:(BOOL)isInstalling {
396  if (!isInstalling == !progressIndicator_)
397    return;
398
399  [self ensureVisible];
400  if (!isInstalling) {
401    [progressIndicator_ removeFromSuperview];
402    progressIndicator_.reset();
403    [self updateButtonTitle];
404    [self setSelected:YES];
405    return;
406  }
407
408  NSRect rect = NSMakeRect(
409      kProgressBarHorizontalPadding,
410      kProgressBarVerticalPadding,
411      NSWidth([[self view] bounds]) - 2 * kProgressBarHorizontalPadding,
412      NSProgressIndicatorPreferredAquaThickness);
413  [[self button] setTitle:@""];
414  progressIndicator_.reset([[NSProgressIndicator alloc] initWithFrame:rect]);
415  [progressIndicator_ setIndeterminate:NO];
416  [progressIndicator_ setControlSize:NSSmallControlSize];
417  [[self view] addSubview:progressIndicator_];
418}
419
420- (void)setPercentDownloaded:(int)percent {
421  // In a corner case, items can be installing when they are first added. For
422  // those, the icon will start desaturated. Wait for a progress update before
423  // showing the progress bar.
424  [self setItemIsInstalling:YES];
425  if (percent != -1) {
426    [progressIndicator_ setDoubleValue:percent];
427    return;
428  }
429
430  // Otherwise, fully downloaded and waiting for install to complete.
431  [progressIndicator_ setIndeterminate:YES];
432  [progressIndicator_ startAnimation:self];
433}
434
435- (AppsGridItemBackgroundView*)itemBackgroundView {
436  return base::mac::ObjCCastStrict<AppsGridItemBackgroundView>([self view]);
437}
438
439- (void)mouseEntered:(NSEvent*)theEvent {
440  [self setSelected:YES];
441}
442
443- (void)mouseExited:(NSEvent*)theEvent {
444  [self setSelected:NO];
445}
446
447- (void)setSelected:(BOOL)flag {
448  if ([self isSelected] == flag)
449    return;
450
451  [[self itemBackgroundView] setSelected:flag];
452  [super setSelected:flag];
453  [self updateButtonTitle];
454}
455
456@end
457
458@implementation AppsGridItemButton
459
460+ (Class)cellClass {
461  return [AppsGridItemButtonCell class];
462}
463
464@end
465
466@implementation AppsGridItemButtonCell
467
468@synthesize hasShadow = hasShadow_;
469
470- (void)drawImage:(NSImage*)image
471        withFrame:(NSRect)frame
472           inView:(NSView*)controlView {
473  if (!hasShadow_) {
474    [super drawImage:image
475           withFrame:frame
476              inView:controlView];
477    return;
478  }
479
480  base::scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]);
481  gfx::ScopedNSGraphicsContextSaveGState context;
482  [shadow setShadowOffset:NSMakeSize(0, -2)];
483  [shadow setShadowBlurRadius:2.0];
484  [shadow setShadowColor:[NSColor colorWithCalibratedWhite:0
485                                                     alpha:0.14]];
486  [shadow set];
487
488  [super drawImage:image
489         withFrame:frame
490            inView:controlView];
491}
492
493// Workaround for http://crbug.com/324365: AppKit in Mavericks tries to call
494// - [NSButtonCell item] when inspecting accessibility. Without this, an
495// unrecognized selector exception is thrown inside AppKit, crashing Chrome.
496- (id)item {
497  return nil;
498}
499
500@end
501