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_search_results_controller.h"
6
7#include "base/mac/foundation_util.h"
8#include "base/mac/mac_util.h"
9#include "base/message_loop/message_loop.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_model.h"
14#import "ui/app_list/cocoa/apps_search_results_model_bridge.h"
15#include "ui/app_list/search_result.h"
16#import "ui/base/cocoa/flipped_view.h"
17#include "ui/gfx/image/image_skia_util_mac.h"
18
19namespace {
20
21const CGFloat kPreferredRowHeight = 52;
22const CGFloat kIconDimension = 32;
23const CGFloat kIconPadding = 14;
24const CGFloat kIconViewWidth = kIconDimension + 2 * kIconPadding;
25const CGFloat kTextTrailPadding = kIconPadding;
26
27// Map background styles to represent selection and hover in the results list.
28const NSBackgroundStyle kBackgroundNormal = NSBackgroundStyleLight;
29const NSBackgroundStyle kBackgroundSelected = NSBackgroundStyleDark;
30const NSBackgroundStyle kBackgroundHovered = NSBackgroundStyleRaised;
31
32}  // namespace
33
34@interface AppsSearchResultsController ()
35
36- (void)loadAndSetViewWithResultsFrameSize:(NSSize)size;
37- (void)mouseDown:(NSEvent*)theEvent;
38- (void)tableViewClicked:(id)sender;
39- (app_list::AppListModel::SearchResults*)searchResults;
40- (void)activateSelection;
41- (BOOL)moveSelectionByDelta:(NSInteger)delta;
42- (NSMenu*)contextMenuForRow:(NSInteger)rowIndex;
43
44@end
45
46@interface AppsSearchResultsCell : NSTextFieldCell
47@end
48
49// Immutable class representing a search result in the NSTableView.
50@interface AppsSearchResultRep : NSObject<NSCopying> {
51 @private
52  base::scoped_nsobject<NSAttributedString> attributedStringValue_;
53  base::scoped_nsobject<NSImage> resultIcon_;
54}
55
56@property(readonly, nonatomic) NSAttributedString* attributedStringValue;
57@property(readonly, nonatomic) NSImage* resultIcon;
58
59- (id)initWithSearchResult:(app_list::SearchResult*)result;
60
61- (NSMutableAttributedString*)createRenderText:(const base::string16&)content
62    tags:(const app_list::SearchResult::Tags&)tags;
63
64- (NSAttributedString*)createResultsAttributedStringWithModel
65    :(app_list::SearchResult*)result;
66
67@end
68
69// Simple extension to NSTableView that passes mouseDown events to the
70// delegate so that drag events can be detected, and forwards requests for
71// context menus.
72@interface AppsSearchResultsTableView : NSTableView
73
74- (AppsSearchResultsController*)controller;
75
76@end
77
78@implementation AppsSearchResultsController
79
80@synthesize delegate = delegate_;
81
82- (id)initWithAppsSearchResultsFrameSize:(NSSize)size {
83  if ((self = [super init])) {
84    hoveredRowIndex_ = -1;
85    [self loadAndSetViewWithResultsFrameSize:size];
86  }
87  return self;
88}
89
90- (app_list::AppListModel::SearchResults*)results {
91  DCHECK([delegate_ appListModel]);
92  return [delegate_ appListModel]->results();
93}
94
95- (NSTableView*)tableView {
96  return tableView_;
97}
98
99- (void)setDelegate:(id<AppsSearchResultsDelegate>)newDelegate {
100  bridge_.reset();
101  delegate_ = newDelegate;
102  app_list::AppListModel* appListModel = [delegate_ appListModel];
103  if (!appListModel || !appListModel->results()) {
104    [tableView_ reloadData];
105    return;
106  }
107
108  bridge_.reset(new app_list::AppsSearchResultsModelBridge(self));
109  [tableView_ reloadData];
110}
111
112- (BOOL)handleCommandBySelector:(SEL)command {
113  if (command == @selector(insertNewline:) ||
114      command == @selector(insertLineBreak:)) {
115    [self activateSelection];
116    return YES;
117  }
118
119  if (command == @selector(moveUp:))
120    return [self moveSelectionByDelta:-1];
121
122  if (command == @selector(moveDown:))
123    return [self moveSelectionByDelta:1];
124
125  return NO;
126}
127
128- (void)loadAndSetViewWithResultsFrameSize:(NSSize)size {
129  tableView_.reset(
130      [[AppsSearchResultsTableView alloc] initWithFrame:NSZeroRect]);
131  // Refuse first responder so that focus stays with the search text field.
132  [tableView_ setRefusesFirstResponder:YES];
133  [tableView_ setRowHeight:kPreferredRowHeight];
134  [tableView_ setGridStyleMask:NSTableViewSolidHorizontalGridLineMask];
135  [tableView_ setGridColor:
136      gfx::SkColorToSRGBNSColor(app_list::kResultBorderColor)];
137  [tableView_ setBackgroundColor:[NSColor clearColor]];
138  [tableView_ setAction:@selector(tableViewClicked:)];
139  [tableView_ setDelegate:self];
140  [tableView_ setDataSource:self];
141  [tableView_ setTarget:self];
142
143  // Tracking to highlight an individual row on mouseover.
144  trackingArea_.reset(
145    [[CrTrackingArea alloc] initWithRect:NSZeroRect
146                                 options:NSTrackingInVisibleRect |
147                                         NSTrackingMouseEnteredAndExited |
148                                         NSTrackingMouseMoved |
149                                         NSTrackingActiveInKeyWindow
150                                   owner:self
151                                userInfo:nil]);
152  [tableView_ addTrackingArea:trackingArea_.get()];
153
154  base::scoped_nsobject<NSTableColumn> resultsColumn(
155      [[NSTableColumn alloc] initWithIdentifier:@""]);
156  base::scoped_nsobject<NSCell> resultsDataCell(
157      [[AppsSearchResultsCell alloc] initTextCell:@""]);
158  [resultsColumn setDataCell:resultsDataCell];
159  [resultsColumn setWidth:size.width];
160  [tableView_ addTableColumn:resultsColumn];
161
162  // An NSTableView is normally put in a NSScrollView, but scrolling is not
163  // used for the app list. Instead, place it in a container with the desired
164  // size; flipped so the table is anchored to the top-left.
165  base::scoped_nsobject<FlippedView> containerView([[FlippedView alloc]
166      initWithFrame:NSMakeRect(0, 0, size.width, size.height)]);
167
168  // The container is then anchored in an un-flipped view, initially hidden,
169  // so that |containerView| slides in from the top when showing results.
170  base::scoped_nsobject<NSView> clipView(
171      [[NSView alloc] initWithFrame:NSMakeRect(0, 0, size.width, 0)]);
172
173  [containerView addSubview:tableView_];
174  [clipView addSubview:containerView];
175  [self setView:clipView];
176}
177
178- (void)mouseDown:(NSEvent*)theEvent {
179  lastMouseDownInView_ = [tableView_ convertPoint:[theEvent locationInWindow]
180                                         fromView:nil];
181}
182
183- (void)tableViewClicked:(id)sender {
184  const CGFloat kDragThreshold = 5;
185  // If the user clicked and then dragged elsewhere, ignore the click.
186  NSEvent* event = [[tableView_ window] currentEvent];
187  NSPoint pointInView = [tableView_ convertPoint:[event locationInWindow]
188                                        fromView:nil];
189  CGFloat deltaX = pointInView.x - lastMouseDownInView_.x;
190  CGFloat deltaY = pointInView.y - lastMouseDownInView_.y;
191  if (deltaX * deltaX + deltaY * deltaY <= kDragThreshold * kDragThreshold)
192    [self activateSelection];
193
194  // Mouse tracking is suppressed by the NSTableView during a drag, so ensure
195  // any hover state is cleaned up.
196  [self mouseMoved:event];
197}
198
199- (app_list::AppListModel::SearchResults*)searchResults {
200  app_list::AppListModel* appListModel = [delegate_ appListModel];
201  DCHECK(bridge_);
202  DCHECK(appListModel);
203  DCHECK(appListModel->results());
204  return appListModel->results();
205}
206
207- (void)activateSelection {
208  NSInteger selectedRow = [tableView_ selectedRow];
209  if (!bridge_ || selectedRow < 0)
210    return;
211
212  [delegate_ openResult:[self searchResults]->GetItemAt(selectedRow)];
213}
214
215- (BOOL)moveSelectionByDelta:(NSInteger)delta {
216  NSInteger rowCount = [tableView_ numberOfRows];
217  if (rowCount <= 0)
218    return NO;
219
220  NSInteger selectedRow = [tableView_ selectedRow];
221  NSInteger targetRow;
222  if (selectedRow == -1) {
223    // No selection. Select first or last, based on direction.
224    targetRow = delta > 0 ? 0 : rowCount - 1;
225  } else {
226    targetRow = (selectedRow + delta) % rowCount;
227    if (targetRow < 0)
228      targetRow += rowCount;
229  }
230
231  [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:targetRow]
232          byExtendingSelection:NO];
233  return YES;
234}
235
236- (NSMenu*)contextMenuForRow:(NSInteger)rowIndex {
237  DCHECK(bridge_);
238  if (rowIndex < 0)
239    return nil;
240
241  [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:rowIndex]
242          byExtendingSelection:NO];
243  return bridge_->MenuForItem(rowIndex);
244}
245
246- (NSInteger)numberOfRowsInTableView:(NSTableView*)aTableView {
247  return bridge_ ? [self searchResults]->item_count() : 0;
248}
249
250- (id)tableView:(NSTableView*)aTableView
251    objectValueForTableColumn:(NSTableColumn*)aTableColumn
252                          row:(NSInteger)rowIndex {
253  // When the results were previously cleared, nothing will be selected. For
254  // that case, select the first row when it appears.
255  if (rowIndex == 0 && [tableView_ selectedRow] == -1) {
256    [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:0]
257            byExtendingSelection:NO];
258  }
259
260  base::scoped_nsobject<AppsSearchResultRep> resultRep(
261      [[AppsSearchResultRep alloc]
262          initWithSearchResult:[self searchResults]->GetItemAt(rowIndex)]);
263  return resultRep.autorelease();
264}
265
266- (void)tableView:(NSTableView*)tableView
267    willDisplayCell:(id)cell
268     forTableColumn:(NSTableColumn*)tableColumn
269                row:(NSInteger)rowIndex {
270  if (rowIndex == [tableView selectedRow])
271    [cell setBackgroundStyle:kBackgroundSelected];
272  else if (rowIndex == hoveredRowIndex_)
273    [cell setBackgroundStyle:kBackgroundHovered];
274  else
275    [cell setBackgroundStyle:kBackgroundNormal];
276}
277
278- (void)mouseExited:(NSEvent*)theEvent {
279  if (hoveredRowIndex_ == -1)
280    return;
281
282  [tableView_ setNeedsDisplayInRect:[tableView_ rectOfRow:hoveredRowIndex_]];
283  hoveredRowIndex_ = -1;
284}
285
286- (void)mouseMoved:(NSEvent*)theEvent {
287  NSPoint pointInView = [tableView_ convertPoint:[theEvent locationInWindow]
288                                        fromView:nil];
289  NSInteger newIndex = [tableView_ rowAtPoint:pointInView];
290  if (newIndex == hoveredRowIndex_)
291    return;
292
293  if (newIndex != -1)
294    [tableView_ setNeedsDisplayInRect:[tableView_ rectOfRow:newIndex]];
295  if (hoveredRowIndex_ != -1)
296    [tableView_ setNeedsDisplayInRect:[tableView_ rectOfRow:hoveredRowIndex_]];
297  hoveredRowIndex_ = newIndex;
298}
299
300@end
301
302@implementation AppsSearchResultRep
303
304- (NSAttributedString*)attributedStringValue {
305  return attributedStringValue_;
306}
307
308- (NSImage*)resultIcon {
309  return resultIcon_;
310}
311
312- (id)initWithSearchResult:(app_list::SearchResult*)result {
313  if ((self = [super init])) {
314    attributedStringValue_.reset(
315        [[self createResultsAttributedStringWithModel:result] retain]);
316    if (!result->icon().isNull()) {
317      resultIcon_.reset([gfx::NSImageFromImageSkiaWithColorSpace(
318          result->icon(), base::mac::GetSRGBColorSpace()) retain]);
319    }
320  }
321  return self;
322}
323
324- (NSMutableAttributedString*)createRenderText:(const base::string16&)content
325    tags:(const app_list::SearchResult::Tags&)tags {
326  NSFont* boldFont = nil;
327  base::scoped_nsobject<NSMutableParagraphStyle> paragraphStyle(
328      [[NSMutableParagraphStyle alloc] init]);
329  [paragraphStyle setLineBreakMode:NSLineBreakByTruncatingTail];
330  NSDictionary* defaultAttributes = @{
331      NSForegroundColorAttributeName:
332          gfx::SkColorToSRGBNSColor(app_list::kResultDefaultTextColor),
333      NSParagraphStyleAttributeName: paragraphStyle
334  };
335
336  base::scoped_nsobject<NSMutableAttributedString> text(
337      [[NSMutableAttributedString alloc]
338          initWithString:base::SysUTF16ToNSString(content)
339              attributes:defaultAttributes]);
340
341  for (app_list::SearchResult::Tags::const_iterator it = tags.begin();
342       it != tags.end(); ++it) {
343    if (it->styles == app_list::SearchResult::Tag::NONE)
344      continue;
345
346    if (it->styles & app_list::SearchResult::Tag::MATCH) {
347      if (!boldFont) {
348        NSFontManager* fontManager = [NSFontManager sharedFontManager];
349        boldFont = [fontManager convertFont:[NSFont controlContentFontOfSize:0]
350                                toHaveTrait:NSBoldFontMask];
351      }
352      [text addAttribute:NSFontAttributeName
353                   value:boldFont
354                   range:it->range.ToNSRange()];
355    }
356
357    if (it->styles & app_list::SearchResult::Tag::DIM) {
358      NSColor* dimmedColor =
359          gfx::SkColorToSRGBNSColor(app_list::kResultDimmedTextColor);
360      [text addAttribute:NSForegroundColorAttributeName
361                   value:dimmedColor
362                   range:it->range.ToNSRange()];
363    } else if (it->styles & app_list::SearchResult::Tag::URL) {
364      NSColor* urlColor =
365          gfx::SkColorToSRGBNSColor(app_list::kResultURLTextColor);
366      [text addAttribute:NSForegroundColorAttributeName
367                   value:urlColor
368                   range:it->range.ToNSRange()];
369    }
370  }
371
372  return text.autorelease();
373}
374
375- (NSAttributedString*)createResultsAttributedStringWithModel
376    :(app_list::SearchResult*)result {
377  NSMutableAttributedString* titleText =
378      [self createRenderText:result->title()
379                        tags:result->title_tags()];
380  if (!result->details().empty()) {
381    NSMutableAttributedString* detailText =
382        [self createRenderText:result->details()
383                          tags:result->details_tags()];
384    base::scoped_nsobject<NSAttributedString> lineBreak(
385        [[NSAttributedString alloc] initWithString:@"\n"]);
386    [titleText appendAttributedString:lineBreak];
387    [titleText appendAttributedString:detailText];
388  }
389  return titleText;
390}
391
392- (id)copyWithZone:(NSZone*)zone {
393  return [self retain];
394}
395
396@end
397
398@implementation AppsSearchResultsTableView
399
400- (AppsSearchResultsController*)controller {
401  return base::mac::ObjCCastStrict<AppsSearchResultsController>(
402      [self delegate]);
403}
404
405- (BOOL)canDraw {
406  // AppKit doesn't call -[NSView canDrawConcurrently] which would have told it
407  // that this is unsafe. Returning true from canDraw only if there is a message
408  // loop ensures that no drawing occurs on a background thread. Without this,
409  // ImageSkia can assert when trying to get bitmaps. http://crbug.com/417148.
410  // This means unit tests will always return 'NO', but that's OK.
411  return !!base::MessageLoop::current() && [super canDraw];
412}
413
414- (void)mouseDown:(NSEvent*)theEvent {
415  [[self controller] mouseDown:theEvent];
416  [super mouseDown:theEvent];
417}
418
419- (NSMenu*)menuForEvent:(NSEvent*)theEvent {
420  NSPoint pointInView = [self convertPoint:[theEvent locationInWindow]
421                                  fromView:nil];
422  return [[self controller] contextMenuForRow:[self rowAtPoint:pointInView]];
423}
424
425@end
426
427@implementation AppsSearchResultsCell
428
429- (void)drawWithFrame:(NSRect)cellFrame
430               inView:(NSView*)controlView {
431  if ([self backgroundStyle] != kBackgroundNormal) {
432    if ([self backgroundStyle] == kBackgroundSelected)
433      [gfx::SkColorToSRGBNSColor(app_list::kSelectedColor) set];
434    else
435      [gfx::SkColorToSRGBNSColor(app_list::kHighlightedColor) set];
436
437    // Extend up by one pixel to draw over cell border.
438    NSRect backgroundRect = cellFrame;
439    backgroundRect.origin.y -= 1;
440    backgroundRect.size.height += 1;
441    NSRectFill(backgroundRect);
442  }
443
444  NSAttributedString* titleText = [self attributedStringValue];
445  NSRect titleRect = cellFrame;
446  titleRect.size.width -= kTextTrailPadding + kIconViewWidth;
447  titleRect.origin.x += kIconViewWidth;
448  titleRect.origin.y +=
449      floor(NSHeight(cellFrame) / 2 - [titleText size].height / 2);
450  // Ensure no drawing occurs outside of the cell.
451  titleRect = NSIntersectionRect(titleRect, cellFrame);
452
453  [titleText drawInRect:titleRect];
454
455  NSImage* resultIcon = [[self objectValue] resultIcon];
456  if (!resultIcon)
457    return;
458
459  NSSize iconSize = [resultIcon size];
460  NSRect iconRect = NSMakeRect(
461      floor(NSMinX(cellFrame) + kIconViewWidth / 2 - iconSize.width / 2),
462      floor(NSMinY(cellFrame) + kPreferredRowHeight / 2 - iconSize.height / 2),
463      std::min(iconSize.width, kIconDimension),
464      std::min(iconSize.height, kIconDimension));
465  [resultIcon drawInRect:iconRect
466                fromRect:NSZeroRect
467               operation:NSCompositeSourceOver
468                fraction:1.0
469          respectFlipped:YES
470                   hints:nil];
471}
472
473@end
474