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