apps_grid_view_item.mm revision a3f6a49ab37290eeeb8db0f41ec0f1cb74a68be7
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
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::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
65namespace app_list {
66
67class 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.
84  base::scoped_nsobject<MenuController> context_menu_controller_;
85
86  DISALLOW_COPY_AND_ASSIGN(ItemModelObserverBridge);
87};
88
89ItemModelObserverBridge::ItemModelObserverBridge(AppsGridViewItem* parent,
90                                                 AppListItemModel* 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::ItemTitleChanged() {
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        .GetFont(app_list::kItemTextFontStyle)
251        .DeriveFont(kMacFontSizeDelta)
252        .GetNativeFont(),
253    NSForegroundColorAttributeName : [self isSelected] ?
254        gfx::SkColorToSRGBNSColor(app_list::kGridTitleHoverColor) :
255        gfx::SkColorToSRGBNSColor(app_list::kGridTitleColor)
256  };
257  NSString* buttonTitle = base::SysUTF8ToNSString([self model]->title());
258  base::scoped_nsobject<NSAttributedString> attributedTitle(
259      [[NSAttributedString alloc] initWithString:buttonTitle
260                                      attributes:titleAttributes]);
261  [[self button] setAttributedTitle:attributedTitle];
262
263  // If the app does not specify a distinct short name manifest property, check
264  // whether the title would be truncted in the NSButton. If it would be
265  // truncated, add a tooltip showing the full name.
266  NSRect titleRect =
267      [[[self button] cell] titleRectForBounds:[[self button] bounds]];
268  if ([self model]->title() == [self model]->full_name() &&
269      [attributedTitle size].width < NSWidth(titleRect)) {
270    [[self view] removeAllToolTips];
271  } else {
272    [[self view] setToolTip:base::SysUTF8ToNSString([self model]->full_name())];
273  }
274}
275
276- (void)updateButtonImage {
277  const gfx::Size iconSize = gfx::Size(kIconSize, kIconSize);
278  gfx::ImageSkia icon = [self model]->icon();
279  if (icon.size() != iconSize) {
280    icon = gfx::ImageSkiaOperations::CreateResizedImage(
281        icon, skia::ImageOperations::RESIZE_BEST, iconSize);
282  }
283  NSImage* buttonImage = gfx::NSImageFromImageSkiaWithColorSpace(
284      icon, base::mac::GetSRGBColorSpace());
285  [[self button] setImage:buttonImage];
286  [[[self button] cell] setHasShadow:[self model]->has_shadow()];
287}
288
289- (void)setModel:(app_list::AppListItemModel*)itemModel {
290  [trackingArea_.get() clearOwner];
291  if (!itemModel) {
292    observerBridge_.reset();
293    return;
294  }
295
296  observerBridge_.reset(new app_list::ItemModelObserverBridge(self, itemModel));
297  [self updateButtonTitle];
298  [self updateButtonImage];
299
300  if (trackingArea_.get())
301    [[self view] removeTrackingArea:trackingArea_.get()];
302
303  trackingArea_.reset(
304      [[CrTrackingArea alloc] initWithRect:NSZeroRect
305                                   options:NSTrackingInVisibleRect |
306                                           NSTrackingMouseEnteredAndExited |
307                                           NSTrackingActiveInKeyWindow
308                                     owner:self
309                                  userInfo:nil]);
310  [[self view] addTrackingArea:trackingArea_.get()];
311}
312
313- (app_list::AppListItemModel*)model {
314  return observerBridge_->model();
315}
316
317- (NSButton*)button {
318  return [[self itemBackgroundView] button];
319}
320
321- (NSMenu*)contextMenu {
322  [self setSelected:YES];
323  return observerBridge_->GetContextMenu();
324}
325
326- (NSBitmapImageRep*)dragRepresentationForRestore:(BOOL)isRestore {
327  NSButton* button = [self button];
328  NSView* itemView = [self view];
329
330  // The snapshot is never drawn as if it was selected. Also remove the cell
331  // highlight on the button image, added when it was clicked.
332  [button setHidden:NO];
333  [[button cell] setHighlighted:NO];
334  [self setSelected:NO];
335  [progressIndicator_ setHidden:YES];
336  if (isRestore)
337    [self updateButtonTitle];
338  else
339    [button setTitle:@""];
340
341  NSBitmapImageRep* imageRep =
342      [itemView bitmapImageRepForCachingDisplayInRect:[itemView visibleRect]];
343  [itemView cacheDisplayInRect:[itemView visibleRect]
344              toBitmapImageRep:imageRep];
345
346  if (isRestore) {
347    [progressIndicator_ setHidden:NO];
348    [self setSelected:YES];
349  }
350  // Button is always hidden until the drag animation completes.
351  [button setHidden:YES];
352  return imageRep;
353}
354
355- (void)ensureVisible {
356  NSCollectionView* collectionView = [self collectionView];
357  AppsGridController* gridController =
358      base::mac::ObjCCastStrict<AppsGridController>([collectionView delegate]);
359  size_t pageIndex = [gridController pageIndexForCollectionView:collectionView];
360  [gridController scrollToPage:pageIndex];
361}
362
363- (void)setItemIsInstalling:(BOOL)isInstalling {
364  if (!isInstalling == !progressIndicator_)
365    return;
366
367  [self ensureVisible];
368  if (!isInstalling) {
369    [progressIndicator_ removeFromSuperview];
370    progressIndicator_.reset();
371    [self updateButtonTitle];
372    [self setSelected:YES];
373    return;
374  }
375
376  NSRect rect = NSMakeRect(
377      kProgressBarHorizontalPadding,
378      kProgressBarVerticalPadding,
379      NSWidth([[self view] bounds]) - 2 * kProgressBarHorizontalPadding,
380      NSProgressIndicatorPreferredAquaThickness);
381  [[self button] setTitle:@""];
382  progressIndicator_.reset([[NSProgressIndicator alloc] initWithFrame:rect]);
383  [progressIndicator_ setIndeterminate:NO];
384  [progressIndicator_ setControlSize:NSSmallControlSize];
385  [[self view] addSubview:progressIndicator_];
386}
387
388- (void)setPercentDownloaded:(int)percent {
389  // In a corner case, items can be installing when they are first added. For
390  // those, the icon will start desaturated. Wait for a progress update before
391  // showing the progress bar.
392  [self setItemIsInstalling:YES];
393  if (percent != -1) {
394    [progressIndicator_ setDoubleValue:percent];
395    return;
396  }
397
398  // Otherwise, fully downloaded and waiting for install to complete.
399  [progressIndicator_ setIndeterminate:YES];
400  [progressIndicator_ startAnimation:self];
401}
402
403- (AppsGridItemBackgroundView*)itemBackgroundView {
404  return base::mac::ObjCCastStrict<AppsGridItemBackgroundView>([self view]);
405}
406
407- (void)mouseEntered:(NSEvent*)theEvent {
408  [self setSelected:YES];
409}
410
411- (void)mouseExited:(NSEvent*)theEvent {
412  [self setSelected:NO];
413}
414
415- (void)setSelected:(BOOL)flag {
416  if ([self isSelected] == flag)
417    return;
418
419  [[self itemBackgroundView] setSelected:flag];
420  [super setSelected:flag];
421  [self updateButtonTitle];
422}
423
424@end
425
426@implementation AppsGridItemButton
427
428+ (Class)cellClass {
429  return [AppsGridItemButtonCell class];
430}
431
432@end
433
434@implementation AppsGridItemButtonCell
435
436@synthesize hasShadow = hasShadow_;
437
438- (void)drawImage:(NSImage*)image
439        withFrame:(NSRect)frame
440           inView:(NSView*)controlView {
441  if (!hasShadow_) {
442    [super drawImage:image
443           withFrame:frame
444              inView:controlView];
445    return;
446  }
447
448  base::scoped_nsobject<NSShadow> shadow([[NSShadow alloc] init]);
449  gfx::ScopedNSGraphicsContextSaveGState context;
450  [shadow setShadowOffset:NSMakeSize(0, -2)];
451  [shadow setShadowBlurRadius:2.0];
452  [shadow setShadowColor:[NSColor colorWithCalibratedWhite:0
453                                                     alpha:0.14]];
454  [shadow set];
455
456  [super drawImage:image
457         withFrame:frame
458            inView:controlView];
459}
460
461// Workaround for http://crbug.com/324365: AppKit in Mavericks tries to call
462// - [NSButtonCell item] when inspecting accessibility. Without this, an
463// unrecognized selector exception is thrown inside AppKit, crashing Chrome.
464- (id)item {
465  return nil;
466}
467
468@end
469