| // 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; |
| |
| } // 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<NSCopying> { |
| @private |
| base::scoped_nsobject<NSAttributedString> attributedStringValue_; |
| base::scoped_nsobject<NSImage> 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<AppsSearchResultsDelegate>)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<NSTableColumn> resultsColumn( |
| [[NSTableColumn alloc] initWithIdentifier:@""]); |
| base::scoped_nsobject<NSCell> 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<FlippedView> 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<NSView> 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<AppsSearchResultRep> 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<NSMutableParagraphStyle> paragraphStyle( |
| [[NSMutableParagraphStyle alloc] init]); |
| [paragraphStyle setLineBreakMode:NSLineBreakByTruncatingTail]; |
| NSDictionary* defaultAttributes = @{ |
| NSForegroundColorAttributeName: |
| gfx::SkColorToSRGBNSColor(app_list::kResultDefaultTextColor), |
| NSParagraphStyleAttributeName: paragraphStyle |
| }; |
| |
| base::scoped_nsobject<NSMutableAttributedString> 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<NSAttributedString> 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<AppsSearchResultsController>( |
| [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(app_list::kSelectedColor) set]; |
| else |
| [gfx::SkColorToSRGBNSColor(app_list::kHighlightedColor) 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 |