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