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