// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import "ui/app_list/cocoa/apps_search_results_controller.h" #include "base/mac/foundation_util.h" #include "base/mac/mac_util.h" #include "base/message_loop/message_loop.h" #include "base/strings/sys_string_conversions.h" #include "skia/ext/skia_utils_mac.h" #include "ui/app_list/app_list_constants.h" #include "ui/app_list/app_list_model.h" #import "ui/app_list/cocoa/apps_search_results_model_bridge.h" #include "ui/app_list/search_result.h" #import "ui/base/cocoa/flipped_view.h" #include "ui/gfx/image/image_skia_util_mac.h" namespace { const CGFloat kPreferredRowHeight = 52; const CGFloat kIconDimension = 32; const CGFloat kIconPadding = 14; const CGFloat kIconViewWidth = kIconDimension + 2 * kIconPadding; const CGFloat kTextTrailPadding = kIconPadding; // Map background styles to represent selection and hover in the results list. const NSBackgroundStyle kBackgroundNormal = NSBackgroundStyleLight; const NSBackgroundStyle kBackgroundSelected = NSBackgroundStyleDark; const NSBackgroundStyle kBackgroundHovered = NSBackgroundStyleRaised; // The mouse hover colour (3% black over kContentsBackgroundColor). const SkColor kHighlightedRowColor = SkColorSetRGB(0xEE, 0xEE, 0xEE); // The keyboard select colour (6% black over kContentsBackgroundColor). const SkColor kSelectedRowColor = SkColorSetRGB(0xE6, 0xE6, 0xE6); } // namespace @interface AppsSearchResultsController () - (void)loadAndSetViewWithResultsFrameSize:(NSSize)size; - (void)mouseDown:(NSEvent*)theEvent; - (void)tableViewClicked:(id)sender; - (app_list::AppListModel::SearchResults*)searchResults; - (void)activateSelection; - (BOOL)moveSelectionByDelta:(NSInteger)delta; - (NSMenu*)contextMenuForRow:(NSInteger)rowIndex; @end @interface AppsSearchResultsCell : NSTextFieldCell @end // Immutable class representing a search result in the NSTableView. @interface AppsSearchResultRep : NSObject { @private base::scoped_nsobject attributedStringValue_; base::scoped_nsobject resultIcon_; } @property(readonly, nonatomic) NSAttributedString* attributedStringValue; @property(readonly, nonatomic) NSImage* resultIcon; - (id)initWithSearchResult:(app_list::SearchResult*)result; - (NSMutableAttributedString*)createRenderText:(const base::string16&)content tags:(const app_list::SearchResult::Tags&)tags; - (NSAttributedString*)createResultsAttributedStringWithModel :(app_list::SearchResult*)result; @end // Simple extension to NSTableView that passes mouseDown events to the // delegate so that drag events can be detected, and forwards requests for // context menus. @interface AppsSearchResultsTableView : NSTableView - (AppsSearchResultsController*)controller; @end @implementation AppsSearchResultsController @synthesize delegate = delegate_; - (id)initWithAppsSearchResultsFrameSize:(NSSize)size { if ((self = [super init])) { hoveredRowIndex_ = -1; [self loadAndSetViewWithResultsFrameSize:size]; } return self; } - (app_list::AppListModel::SearchResults*)results { DCHECK([delegate_ appListModel]); return [delegate_ appListModel]->results(); } - (NSTableView*)tableView { return tableView_; } - (void)setDelegate:(id)newDelegate { bridge_.reset(); delegate_ = newDelegate; app_list::AppListModel* appListModel = [delegate_ appListModel]; if (!appListModel || !appListModel->results()) { [tableView_ reloadData]; return; } bridge_.reset(new app_list::AppsSearchResultsModelBridge(self)); [tableView_ reloadData]; } - (BOOL)handleCommandBySelector:(SEL)command { if (command == @selector(insertNewline:) || command == @selector(insertLineBreak:)) { [self activateSelection]; return YES; } if (command == @selector(moveUp:)) return [self moveSelectionByDelta:-1]; if (command == @selector(moveDown:)) return [self moveSelectionByDelta:1]; return NO; } - (void)loadAndSetViewWithResultsFrameSize:(NSSize)size { tableView_.reset( [[AppsSearchResultsTableView alloc] initWithFrame:NSZeroRect]); // Refuse first responder so that focus stays with the search text field. [tableView_ setRefusesFirstResponder:YES]; [tableView_ setRowHeight:kPreferredRowHeight]; [tableView_ setGridStyleMask:NSTableViewSolidHorizontalGridLineMask]; [tableView_ setGridColor: gfx::SkColorToSRGBNSColor(app_list::kResultBorderColor)]; [tableView_ setBackgroundColor:[NSColor clearColor]]; [tableView_ setAction:@selector(tableViewClicked:)]; [tableView_ setDelegate:self]; [tableView_ setDataSource:self]; [tableView_ setTarget:self]; // Tracking to highlight an individual row on mouseover. trackingArea_.reset( [[CrTrackingArea alloc] initWithRect:NSZeroRect options:NSTrackingInVisibleRect | NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | NSTrackingActiveInKeyWindow owner:self userInfo:nil]); [tableView_ addTrackingArea:trackingArea_.get()]; base::scoped_nsobject resultsColumn( [[NSTableColumn alloc] initWithIdentifier:@""]); base::scoped_nsobject resultsDataCell( [[AppsSearchResultsCell alloc] initTextCell:@""]); [resultsColumn setDataCell:resultsDataCell]; [resultsColumn setWidth:size.width]; [tableView_ addTableColumn:resultsColumn]; // An NSTableView is normally put in a NSScrollView, but scrolling is not // used for the app list. Instead, place it in a container with the desired // size; flipped so the table is anchored to the top-left. base::scoped_nsobject containerView([[FlippedView alloc] initWithFrame:NSMakeRect(0, 0, size.width, size.height)]); // The container is then anchored in an un-flipped view, initially hidden, // so that |containerView| slides in from the top when showing results. base::scoped_nsobject clipView( [[NSView alloc] initWithFrame:NSMakeRect(0, 0, size.width, 0)]); [containerView addSubview:tableView_]; [clipView addSubview:containerView]; [self setView:clipView]; } - (void)mouseDown:(NSEvent*)theEvent { lastMouseDownInView_ = [tableView_ convertPoint:[theEvent locationInWindow] fromView:nil]; } - (void)tableViewClicked:(id)sender { const CGFloat kDragThreshold = 5; // If the user clicked and then dragged elsewhere, ignore the click. NSEvent* event = [[tableView_ window] currentEvent]; NSPoint pointInView = [tableView_ convertPoint:[event locationInWindow] fromView:nil]; CGFloat deltaX = pointInView.x - lastMouseDownInView_.x; CGFloat deltaY = pointInView.y - lastMouseDownInView_.y; if (deltaX * deltaX + deltaY * deltaY <= kDragThreshold * kDragThreshold) [self activateSelection]; // Mouse tracking is suppressed by the NSTableView during a drag, so ensure // any hover state is cleaned up. [self mouseMoved:event]; } - (app_list::AppListModel::SearchResults*)searchResults { app_list::AppListModel* appListModel = [delegate_ appListModel]; DCHECK(bridge_); DCHECK(appListModel); DCHECK(appListModel->results()); return appListModel->results(); } - (void)activateSelection { NSInteger selectedRow = [tableView_ selectedRow]; if (!bridge_ || selectedRow < 0) return; [delegate_ openResult:[self searchResults]->GetItemAt(selectedRow)]; } - (BOOL)moveSelectionByDelta:(NSInteger)delta { NSInteger rowCount = [tableView_ numberOfRows]; if (rowCount <= 0) return NO; NSInteger selectedRow = [tableView_ selectedRow]; NSInteger targetRow; if (selectedRow == -1) { // No selection. Select first or last, based on direction. targetRow = delta > 0 ? 0 : rowCount - 1; } else { targetRow = (selectedRow + delta) % rowCount; if (targetRow < 0) targetRow += rowCount; } [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:targetRow] byExtendingSelection:NO]; return YES; } - (NSMenu*)contextMenuForRow:(NSInteger)rowIndex { DCHECK(bridge_); if (rowIndex < 0) return nil; [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:rowIndex] byExtendingSelection:NO]; return bridge_->MenuForItem(rowIndex); } - (NSInteger)numberOfRowsInTableView:(NSTableView*)aTableView { return bridge_ ? [self searchResults]->item_count() : 0; } - (id)tableView:(NSTableView*)aTableView objectValueForTableColumn:(NSTableColumn*)aTableColumn row:(NSInteger)rowIndex { // When the results were previously cleared, nothing will be selected. For // that case, select the first row when it appears. if (rowIndex == 0 && [tableView_ selectedRow] == -1) { [tableView_ selectRowIndexes:[NSIndexSet indexSetWithIndex:0] byExtendingSelection:NO]; } base::scoped_nsobject resultRep( [[AppsSearchResultRep alloc] initWithSearchResult:[self searchResults]->GetItemAt(rowIndex)]); return resultRep.autorelease(); } - (void)tableView:(NSTableView*)tableView willDisplayCell:(id)cell forTableColumn:(NSTableColumn*)tableColumn row:(NSInteger)rowIndex { if (rowIndex == [tableView selectedRow]) [cell setBackgroundStyle:kBackgroundSelected]; else if (rowIndex == hoveredRowIndex_) [cell setBackgroundStyle:kBackgroundHovered]; else [cell setBackgroundStyle:kBackgroundNormal]; } - (void)mouseExited:(NSEvent*)theEvent { if (hoveredRowIndex_ == -1) return; [tableView_ setNeedsDisplayInRect:[tableView_ rectOfRow:hoveredRowIndex_]]; hoveredRowIndex_ = -1; } - (void)mouseMoved:(NSEvent*)theEvent { NSPoint pointInView = [tableView_ convertPoint:[theEvent locationInWindow] fromView:nil]; NSInteger newIndex = [tableView_ rowAtPoint:pointInView]; if (newIndex == hoveredRowIndex_) return; if (newIndex != -1) [tableView_ setNeedsDisplayInRect:[tableView_ rectOfRow:newIndex]]; if (hoveredRowIndex_ != -1) [tableView_ setNeedsDisplayInRect:[tableView_ rectOfRow:hoveredRowIndex_]]; hoveredRowIndex_ = newIndex; } @end @implementation AppsSearchResultRep - (NSAttributedString*)attributedStringValue { return attributedStringValue_; } - (NSImage*)resultIcon { return resultIcon_; } - (id)initWithSearchResult:(app_list::SearchResult*)result { if ((self = [super init])) { attributedStringValue_.reset( [[self createResultsAttributedStringWithModel:result] retain]); if (!result->icon().isNull()) { resultIcon_.reset([gfx::NSImageFromImageSkiaWithColorSpace( result->icon(), base::mac::GetSRGBColorSpace()) retain]); } } return self; } - (NSMutableAttributedString*)createRenderText:(const base::string16&)content tags:(const app_list::SearchResult::Tags&)tags { NSFont* boldFont = nil; base::scoped_nsobject paragraphStyle( [[NSMutableParagraphStyle alloc] init]); [paragraphStyle setLineBreakMode:NSLineBreakByTruncatingTail]; NSDictionary* defaultAttributes = @{ NSForegroundColorAttributeName: gfx::SkColorToSRGBNSColor(app_list::kResultDefaultTextColor), NSParagraphStyleAttributeName: paragraphStyle }; base::scoped_nsobject text( [[NSMutableAttributedString alloc] initWithString:base::SysUTF16ToNSString(content) attributes:defaultAttributes]); for (app_list::SearchResult::Tags::const_iterator it = tags.begin(); it != tags.end(); ++it) { if (it->styles == app_list::SearchResult::Tag::NONE) continue; if (it->styles & app_list::SearchResult::Tag::MATCH) { if (!boldFont) { NSFontManager* fontManager = [NSFontManager sharedFontManager]; boldFont = [fontManager convertFont:[NSFont controlContentFontOfSize:0] toHaveTrait:NSBoldFontMask]; } [text addAttribute:NSFontAttributeName value:boldFont range:it->range.ToNSRange()]; } if (it->styles & app_list::SearchResult::Tag::DIM) { NSColor* dimmedColor = gfx::SkColorToSRGBNSColor(app_list::kResultDimmedTextColor); [text addAttribute:NSForegroundColorAttributeName value:dimmedColor range:it->range.ToNSRange()]; } else if (it->styles & app_list::SearchResult::Tag::URL) { NSColor* urlColor = gfx::SkColorToSRGBNSColor(app_list::kResultURLTextColor); [text addAttribute:NSForegroundColorAttributeName value:urlColor range:it->range.ToNSRange()]; } } return text.autorelease(); } - (NSAttributedString*)createResultsAttributedStringWithModel :(app_list::SearchResult*)result { NSMutableAttributedString* titleText = [self createRenderText:result->title() tags:result->title_tags()]; if (!result->details().empty()) { NSMutableAttributedString* detailText = [self createRenderText:result->details() tags:result->details_tags()]; base::scoped_nsobject lineBreak( [[NSAttributedString alloc] initWithString:@"\n"]); [titleText appendAttributedString:lineBreak]; [titleText appendAttributedString:detailText]; } return titleText; } - (id)copyWithZone:(NSZone*)zone { return [self retain]; } @end @implementation AppsSearchResultsTableView - (AppsSearchResultsController*)controller { return base::mac::ObjCCastStrict( [self delegate]); } - (void)mouseDown:(NSEvent*)theEvent { [[self controller] mouseDown:theEvent]; [super mouseDown:theEvent]; } - (NSMenu*)menuForEvent:(NSEvent*)theEvent { NSPoint pointInView = [self convertPoint:[theEvent locationInWindow] fromView:nil]; return [[self controller] contextMenuForRow:[self rowAtPoint:pointInView]]; } @end @implementation AppsSearchResultsCell - (void)drawWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { if ([self backgroundStyle] != kBackgroundNormal) { if ([self backgroundStyle] == kBackgroundSelected) [gfx::SkColorToSRGBNSColor(kSelectedRowColor) set]; else [gfx::SkColorToSRGBNSColor(kHighlightedRowColor) set]; // Extend up by one pixel to draw over cell border. NSRect backgroundRect = cellFrame; backgroundRect.origin.y -= 1; backgroundRect.size.height += 1; NSRectFill(backgroundRect); } NSAttributedString* titleText = [self attributedStringValue]; NSRect titleRect = cellFrame; titleRect.size.width -= kTextTrailPadding + kIconViewWidth; titleRect.origin.x += kIconViewWidth; titleRect.origin.y += floor(NSHeight(cellFrame) / 2 - [titleText size].height / 2); // Ensure no drawing occurs outside of the cell. titleRect = NSIntersectionRect(titleRect, cellFrame); [titleText drawInRect:titleRect]; NSImage* resultIcon = [[self objectValue] resultIcon]; if (!resultIcon) return; NSSize iconSize = [resultIcon size]; NSRect iconRect = NSMakeRect( floor(NSMinX(cellFrame) + kIconViewWidth / 2 - iconSize.width / 2), floor(NSMinY(cellFrame) + kPreferredRowHeight / 2 - iconSize.height / 2), std::min(iconSize.width, kIconDimension), std::min(iconSize.height, kIconDimension)); [resultIcon drawInRect:iconRect fromRect:NSZeroRect operation:NSCompositeSourceOver fraction:1.0 respectFlipped:YES hints:nil]; } @end