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