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 "chrome/browser/ui/cocoa/omnibox/omnibox_popup_cell.h"
10 #include "base/i18n/rtl.h"
11 #include "base/mac/foundation_util.h"
12 #include "base/mac/scoped_nsobject.h"
13 #include "base/strings/string_number_conversions.h"
14 #include "base/strings/string_util.h"
15 #include "base/strings/sys_string_conversions.h"
16 #include "base/strings/utf_string_conversions.h"
17 #include "chrome/browser/ui/cocoa/omnibox/omnibox_popup_view_mac.h"
18 #include "chrome/browser/ui/cocoa/omnibox/omnibox_view_mac.h"
19 #include "chrome/grit/generated_resources.h"
20 #include "components/omnibox/browser/omnibox_popup_model.h"
21 #include "components/omnibox/browser/suggestion_answer.h"
22 #include "skia/ext/skia_utils_mac.h"
23 #include "ui/base/l10n/l10n_util.h"
24 #include "ui/gfx/font.h"
28 // How far to offset image column from the left.
29 const CGFloat kImageXOffset = 5.0;
31 // How far to offset image and text.
32 const CGFloat kPaddingOffset = 3.0;
34 // How far to offset the text column from the left.
35 const CGFloat kTextStartOffset = 28.0;
37 // Rounding radius of selection and hover background on popup items.
38 const CGFloat kCellRoundingRadius = 2.0;
40 // Flips the given |rect| in context of the given |frame|.
41 NSRect FlipIfRTL(NSRect rect, NSRect frame) {
42 DCHECK_LE(NSMinX(frame), NSMinX(rect));
43 DCHECK_GE(NSMaxX(frame), NSMaxX(rect));
44 if (base::i18n::IsRTL()) {
46 result.origin.x = NSMinX(frame) + (NSMaxX(frame) - NSMaxX(rect));
52 NSColor* SelectedBackgroundColor() {
53 return [NSColor selectedControlColor];
55 NSColor* HoveredBackgroundColor() {
56 return [NSColor controlHighlightColor];
59 NSColor* ContentTextColor() {
60 return [NSColor blackColor];
62 NSColor* DimTextColor() {
63 return [NSColor darkGrayColor];
65 NSColor* PositiveTextColor() {
66 return gfx::SkColorToCalibratedNSColor(SkColorSetRGB(0x0b, 0x80, 0x43));
68 NSColor* NegativeTextColor() {
69 return gfx::SkColorToCalibratedNSColor(SkColorSetRGB(0xc5, 0x39, 0x29));
71 NSColor* URLTextColor() {
72 return [NSColor colorWithCalibratedRed:0.0 green:0.55 blue:0.0 alpha:1.0];
76 return OmniboxViewMac::GetFieldFont(gfx::Font::NORMAL);
78 NSFont* BoldFieldFont() {
79 return OmniboxViewMac::GetFieldFont(gfx::Font::BOLD);
82 return OmniboxViewMac::GetLargeFont(gfx::Font::NORMAL);
84 NSFont* LargeSuperscriptFont() {
85 NSFont* font = OmniboxViewMac::GetLargeFont(gfx::Font::NORMAL);
86 // Calculate a slightly smaller font. The ratio here is somewhat arbitrary.
87 // Proportions from 5/9 to 5/7 all look pretty good.
88 CGFloat size = [font pointSize] * 5.0 / 9.0;
89 NSFontDescriptor* descriptor = [font fontDescriptor];
90 return [NSFont fontWithDescriptor:descriptor size:size];
93 return OmniboxViewMac::GetSmallFont(gfx::Font::NORMAL);
96 CGFloat GetContentAreaWidth(NSRect cellFrame) {
97 return NSWidth(cellFrame) - kTextStartOffset;
100 NSAttributedString* CreateAnswerStringHelper(const base::string16& text,
101 NSInteger style_type,
103 NSDictionary* answer_style = nil;
104 switch (style_type) {
105 case SuggestionAnswer::ANSWER:
107 NSForegroundColorAttributeName : ContentTextColor(),
108 NSFontAttributeName : LargeFont()
111 case SuggestionAnswer::HEADLINE:
113 NSForegroundColorAttributeName : DimTextColor(),
114 NSFontAttributeName : LargeFont()
117 case SuggestionAnswer::TOP_ALIGNED:
119 NSForegroundColorAttributeName : DimTextColor(),
120 NSFontAttributeName : LargeSuperscriptFont(),
121 NSSuperscriptAttributeName : @1
124 case SuggestionAnswer::DESCRIPTION:
126 NSForegroundColorAttributeName : DimTextColor(),
127 NSFontAttributeName : FieldFont()
130 case SuggestionAnswer::DESCRIPTION_NEGATIVE:
132 NSForegroundColorAttributeName : NegativeTextColor(),
133 NSFontAttributeName : LargeSuperscriptFont()
136 case SuggestionAnswer::DESCRIPTION_POSITIVE:
138 NSForegroundColorAttributeName : PositiveTextColor(),
139 NSFontAttributeName : LargeSuperscriptFont()
142 case SuggestionAnswer::MORE_INFO:
144 NSForegroundColorAttributeName : DimTextColor(),
145 NSFontAttributeName : SmallFont()
148 case SuggestionAnswer::SUGGESTION:
150 NSForegroundColorAttributeName : ContentTextColor(),
151 NSFontAttributeName : FieldFont()
154 case SuggestionAnswer::SUGGESTION_POSITIVE:
156 NSForegroundColorAttributeName : PositiveTextColor(),
157 NSFontAttributeName : FieldFont()
160 case SuggestionAnswer::SUGGESTION_NEGATIVE:
162 NSForegroundColorAttributeName : NegativeTextColor(),
163 NSFontAttributeName : FieldFont()
166 case SuggestionAnswer::SUGGESTION_LINK:
168 NSForegroundColorAttributeName : URLTextColor(),
169 NSFontAttributeName : FieldFont()
172 case SuggestionAnswer::STATUS:
174 NSForegroundColorAttributeName : DimTextColor(),
175 NSFontAttributeName : LargeSuperscriptFont()
178 case SuggestionAnswer::PERSONALIZED_SUGGESTION:
180 NSForegroundColorAttributeName : ContentTextColor(),
181 NSFontAttributeName : FieldFont()
187 NSMutableDictionary* bold_style = [answer_style mutableCopy];
188 // TODO(dschuyler): Account for bolding fonts other than FieldFont.
189 // Field font is the only one currently necessary to bold.
190 [bold_style setObject:BoldFieldFont() forKey:NSFontAttributeName];
191 answer_style = bold_style;
194 return [[[NSAttributedString alloc]
195 initWithString:base::SysUTF16ToNSString(text)
196 attributes:answer_style] autorelease];
199 NSAttributedString* CreateAnswerString(const base::string16& text,
200 NSInteger style_type) {
201 // TODO(dschuyler): make this better. Right now this only supports unnested
202 // bold tags. In the future we'll need to flag unexpected tags while adding
203 // support for b, i, u, sub, and sup. We'll also need to support HTML
204 // entities (< for '<', etc.).
205 const base::string16 begin_tag = base::ASCIIToUTF16("<b>");
206 const base::string16 end_tag = base::ASCIIToUTF16("</b>");
208 base::scoped_nsobject<NSMutableAttributedString> result(
209 [[NSMutableAttributedString alloc] init]);
211 size_t end = text.find(begin_tag, begin);
212 if (end == base::string16::npos) {
214 appendAttributedString:CreateAnswerStringHelper(
219 [result appendAttributedString:CreateAnswerStringHelper(
220 text.substr(begin, end - begin),
222 begin = end + begin_tag.length();
223 end = text.find(end_tag, begin);
224 if (end == base::string16::npos)
226 [result appendAttributedString:CreateAnswerStringHelper(
227 text.substr(begin, end - begin),
229 begin = end + end_tag.length();
231 return result.autorelease();
234 NSAttributedString* CreateAnswerLine(const SuggestionAnswer::ImageLine& line) {
235 base::scoped_nsobject<NSMutableAttributedString> answer_string(
236 [[NSMutableAttributedString alloc] init]);
237 DCHECK(!line.text_fields().empty());
238 for (const SuggestionAnswer::TextField& text_field : line.text_fields()) {
240 appendAttributedString:CreateAnswerString(text_field.text(),
243 const base::string16 space(base::ASCIIToUTF16(" "));
244 const SuggestionAnswer::TextField* text_field = line.additional_text();
247 appendAttributedString:CreateAnswerString(space + text_field->text(),
248 text_field->type())];
250 text_field = line.status_text();
253 appendAttributedString:CreateAnswerString(space + text_field->text(),
254 text_field->type())];
256 base::scoped_nsobject<NSMutableParagraphStyle> style(
257 [[NSMutableParagraphStyle alloc] init]);
258 [style setLineBreakMode:NSLineBreakByTruncatingTail];
259 [style setTighteningFactorForTruncation:0.0];
260 [answer_string addAttribute:NSParagraphStyleAttributeName
262 range:NSMakeRange(0, [answer_string length])];
263 return answer_string.autorelease();
266 NSMutableAttributedString* CreateAttributedString(
267 const base::string16& text,
269 NSTextAlignment textAlignment) {
270 // Start out with a string using the default style info.
271 NSString* s = base::SysUTF16ToNSString(text);
272 NSDictionary* attributes = @{
273 NSFontAttributeName : FieldFont(),
274 NSForegroundColorAttributeName : text_color
276 NSMutableAttributedString* attributedString = [[
277 [NSMutableAttributedString alloc] initWithString:s
278 attributes:attributes] autorelease];
280 NSMutableParagraphStyle* style =
281 [[[NSMutableParagraphStyle alloc] init] autorelease];
282 [style setLineBreakMode:NSLineBreakByTruncatingTail];
283 [style setTighteningFactorForTruncation:0.0];
284 [style setAlignment:textAlignment];
285 [attributedString addAttribute:NSParagraphStyleAttributeName
287 range:NSMakeRange(0, [attributedString length])];
289 return attributedString;
292 NSMutableAttributedString* CreateAttributedString(
293 const base::string16& text,
294 NSColor* text_color) {
295 return CreateAttributedString(text, text_color, NSNaturalTextAlignment);
298 NSAttributedString* CreateClassifiedAttributedString(
299 const base::string16& text,
301 const ACMatchClassifications& classifications) {
302 NSMutableAttributedString* attributedString =
303 CreateAttributedString(text, text_color);
304 NSUInteger match_length = [attributedString length];
306 // Mark up the runs which differ from the default.
307 for (ACMatchClassifications::const_iterator i = classifications.begin();
308 i != classifications.end(); ++i) {
309 const bool is_last = ((i + 1) == classifications.end());
310 const NSUInteger next_offset =
311 (is_last ? match_length : static_cast<NSUInteger>((i + 1)->offset));
312 const NSUInteger location = static_cast<NSUInteger>(i->offset);
313 const NSUInteger length = next_offset - static_cast<NSUInteger>(i->offset);
314 // Guard against bad, off-the-end classification ranges.
315 if (location >= match_length || length <= 0)
317 const NSRange range =
318 NSMakeRange(location, std::min(length, match_length - location));
320 if (0 != (i->style & ACMatchClassification::MATCH)) {
321 [attributedString addAttribute:NSFontAttributeName
322 value:BoldFieldFont()
326 if (0 != (i->style & ACMatchClassification::URL)) {
327 [attributedString addAttribute:NSForegroundColorAttributeName
330 } else if (0 != (i->style & ACMatchClassification::DIM)) {
331 [attributedString addAttribute:NSForegroundColorAttributeName
337 return attributedString;
342 @interface OmniboxPopupCell ()
343 - (CGFloat)drawMatchPart:(NSAttributedString*)attributedString
344 withFrame:(NSRect)cellFrame
345 origin:(NSPoint)origin
346 withMaxWidth:(int)maxWidth;
347 - (CGFloat)drawMatchPrefixWithFrame:(NSRect)cellFrame
348 tableView:(OmniboxPopupMatrix*)tableView
349 withContentsMaxWidth:(int*)contentsMaxWidth;
350 - (void)drawMatchWithFrame:(NSRect)cellFrame inView:(NSView*)controlView;
353 @implementation OmniboxPopupCellData
355 @synthesize contents = contents_;
356 @synthesize description = description_;
357 @synthesize prefix = prefix_;
358 @synthesize image = image_;
359 @synthesize answerImage = answerImage_;
360 @synthesize contentsOffset = contentsOffset_;
361 @synthesize isContentsRTL = isContentsRTL_;
362 @synthesize isAnswer = isAnswer_;
363 @synthesize matchType = matchType_;
365 - (instancetype)initWithMatch:(const AutocompleteMatch&)match
366 contentsOffset:(CGFloat)contentsOffset
367 image:(NSImage*)image
368 answerImage:(NSImage*)answerImage {
369 if ((self = [super init])) {
370 image_ = [image retain];
371 answerImage_ = [answerImage retain];
372 contentsOffset_ = contentsOffset;
375 (base::i18n::RIGHT_TO_LEFT ==
376 base::i18n::GetFirstStrongCharacterDirection(match.contents));
377 matchType_ = match.type;
379 // Prefix may not have any characters with strong directionality, and may
380 // take the UI directionality. But prefix needs to appear in continuation
381 // of the contents so we force the directionality.
382 NSTextAlignment textAlignment =
383 isContentsRTL_ ? NSRightTextAlignment : NSLeftTextAlignment;
385 [CreateAttributedString(base::UTF8ToUTF16(match.GetAdditionalInfo(
386 kACMatchPropertyContentsPrefix)),
387 ContentTextColor(), textAlignment) retain];
389 isAnswer_ = match.answer;
391 contents_ = [CreateAnswerLine(match.answer->first_line()) retain];
392 description_ = [CreateAnswerLine(match.answer->second_line()) retain];
394 contents_ = [CreateClassifiedAttributedString(
395 match.contents, ContentTextColor(), match.contents_class) retain];
396 if (!match.description.empty()) {
397 description_ = [CreateClassifiedAttributedString(
398 match.description, DimTextColor(), match.description_class) retain];
405 - (instancetype)copyWithZone:(NSZone*)zone {
406 return [self retain];
409 - (CGFloat)getMatchContentsWidth {
410 return [contents_ size].width;
415 @implementation OmniboxPopupCell
417 - (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView {
418 if ([self state] == NSOnState || [self isHighlighted]) {
419 if ([self state] == NSOnState)
420 [SelectedBackgroundColor() set];
422 [HoveredBackgroundColor() set];
424 [NSBezierPath bezierPathWithRoundedRect:cellFrame
425 xRadius:kCellRoundingRadius
426 yRadius:kCellRoundingRadius];
430 [self drawMatchWithFrame:cellFrame inView:controlView];
433 - (void)drawMatchWithFrame:(NSRect)cellFrame inView:(NSView*)controlView {
434 OmniboxPopupCellData* cellData =
435 base::mac::ObjCCastStrict<OmniboxPopupCellData>([self objectValue]);
436 OmniboxPopupMatrix* tableView =
437 base::mac::ObjCCastStrict<OmniboxPopupMatrix>(controlView);
438 CGFloat remainingWidth = GetContentAreaWidth(cellFrame);
439 CGFloat contentsWidth = [cellData getMatchContentsWidth];
440 CGFloat separatorWidth = [[tableView separator] size].width;
441 CGFloat descriptionWidth =
442 [cellData description] ? [[cellData description] size].width : 0;
443 int contentsMaxWidth, descriptionMaxWidth;
444 OmniboxPopupModel::ComputeMatchMaxWidths(
445 ceilf(contentsWidth), ceilf(separatorWidth), ceilf(descriptionWidth),
446 ceilf(remainingWidth),
447 !AutocompleteMatch::IsSearchType([cellData matchType]), &contentsMaxWidth,
448 &descriptionMaxWidth);
450 NSRect imageRect = cellFrame;
451 imageRect.size = [[cellData image] size];
452 imageRect.origin.x += kImageXOffset;
453 imageRect.origin.y += kPaddingOffset;
454 [[cellData image] drawInRect:FlipIfRTL(imageRect, cellFrame)
456 operation:NSCompositeSourceOver
461 NSPoint origin = NSMakePoint(kTextStartOffset, kPaddingOffset);
462 if ([cellData matchType] == AutocompleteMatchType::SEARCH_SUGGEST_TAIL) {
463 // Infinite suggestions are rendered with a prefix (usually ellipsis), which
464 // appear vertically stacked.
465 origin.x += [self drawMatchPrefixWithFrame:cellFrame
467 withContentsMaxWidth:&contentsMaxWidth];
469 origin.x += [self drawMatchPart:[cellData contents]
472 withMaxWidth:contentsMaxWidth];
474 if (descriptionMaxWidth > 0) {
475 if ([cellData isAnswer]) {
477 NSMakePoint(kTextStartOffset, kContentLineHeight - kPaddingOffset);
478 CGFloat imageSize = [tableView answerLineHeight];
480 NSMakeRect(NSMinX(cellFrame) + origin.x, NSMinY(cellFrame) + origin.y,
481 imageSize, imageSize);
482 [[cellData answerImage] drawInRect:FlipIfRTL(imageRect, cellFrame)
484 operation:NSCompositeSourceOver
488 if ([cellData answerImage])
489 origin.x += imageSize + kPaddingOffset;
491 origin.x += [self drawMatchPart:[tableView separator]
494 withMaxWidth:separatorWidth];
496 origin.x += [self drawMatchPart:[cellData description]
499 withMaxWidth:descriptionMaxWidth];
503 - (CGFloat)drawMatchPrefixWithFrame:(NSRect)cellFrame
504 tableView:(OmniboxPopupMatrix*)tableView
505 withContentsMaxWidth:(int*)contentsMaxWidth {
506 OmniboxPopupCellData* cellData =
507 base::mac::ObjCCastStrict<OmniboxPopupCellData>([self objectValue]);
508 CGFloat offset = 0.0f;
509 CGFloat remainingWidth = GetContentAreaWidth(cellFrame);
510 CGFloat prefixWidth = [[cellData prefix] size].width;
512 CGFloat prefixOffset = 0.0f;
513 if (base::i18n::IsRTL() != [cellData isContentsRTL]) {
514 // The contents is rendered between the contents offset extending towards
515 // the start edge, while prefix is rendered in opposite direction. Ideally
516 // the prefix should be rendered at |contentsOffset_|. If that is not
517 // sufficient to render the widest suggestion, we increase it to
518 // |maxMatchContentsWidth|. If |remainingWidth| is not sufficient to
519 // accommodate that, we reduce the offset so that the prefix gets rendered.
520 prefixOffset = std::min(
521 remainingWidth - prefixWidth,
522 std::max([cellData contentsOffset], [tableView maxMatchContentsWidth]));
523 offset = std::max<CGFloat>(0.0, prefixOffset - *contentsMaxWidth);
524 } else { // The direction of contents is same as UI direction.
525 // Ideally the offset should be |contentsOffset_|. If the max total width
526 // (|prefixWidth| + |maxMatchContentsWidth|) from offset will exceed the
527 // |remainingWidth|, then we shift the offset to the left , so that all
528 // postfix suggestions are visible.
529 // We have to render the prefix, so offset has to be at least |prefixWidth|.
531 std::max(prefixWidth,
532 std::min(remainingWidth - [tableView maxMatchContentsWidth],
533 [cellData contentsOffset]));
534 prefixOffset = offset - prefixWidth;
536 *contentsMaxWidth = std::min((int)ceilf(remainingWidth - prefixWidth),
538 [self drawMatchPart:[cellData prefix]
540 origin:NSMakePoint(prefixOffset + kTextStartOffset, 0)
541 withMaxWidth:prefixWidth];
545 - (CGFloat)drawMatchPart:(NSAttributedString*)attributedString
546 withFrame:(NSRect)cellFrame
547 origin:(NSPoint)origin
548 withMaxWidth:(int)maxWidth {
549 NSRect renderRect = NSIntersectionRect(
550 cellFrame, NSOffsetRect(cellFrame, origin.x, origin.y));
551 renderRect.size.width =
552 std::min(NSWidth(renderRect), static_cast<CGFloat>(maxWidth));
553 if (!NSIsEmptyRect(renderRect))
554 [attributedString drawInRect:FlipIfRTL(renderRect, cellFrame)];
555 return NSWidth(renderRect);
558 + (CGFloat)computeContentsOffset:(const AutocompleteMatch&)match {
559 const base::string16& inputText = base::UTF8ToUTF16(
560 match.GetAdditionalInfo(kACMatchPropertyInputText));
561 int contentsStartIndex = 0;
563 match.GetAdditionalInfo(kACMatchPropertyContentsStartIndex),
564 &contentsStartIndex);
565 // Ignore invalid state.
566 if (!base::StartsWith(match.fill_into_edit, inputText,
567 base::CompareCase::SENSITIVE) ||
568 !base::EndsWith(match.fill_into_edit, match.contents,
569 base::CompareCase::SENSITIVE) ||
570 ((size_t)contentsStartIndex >= inputText.length())) {
573 bool isContentsRTL = (base::i18n::RIGHT_TO_LEFT ==
574 base::i18n::GetFirstStrongCharacterDirection(match.contents));
576 // Color does not matter.
577 NSAttributedString* attributedString =
578 CreateAttributedString(inputText, DimTextColor());
579 base::scoped_nsobject<NSTextStorage> textStorage(
580 [[NSTextStorage alloc] initWithAttributedString:attributedString]);
581 base::scoped_nsobject<NSLayoutManager> layoutManager(
582 [[NSLayoutManager alloc] init]);
583 base::scoped_nsobject<NSTextContainer> textContainer(
584 [[NSTextContainer alloc] init]);
585 [layoutManager addTextContainer:textContainer];
586 [textStorage addLayoutManager:layoutManager];
588 NSUInteger charIndex = static_cast<NSUInteger>(contentsStartIndex);
589 NSUInteger glyphIndex =
590 [layoutManager glyphIndexForCharacterAtIndex:charIndex];
592 // This offset is computed from the left edge of the glyph always from the
593 // left edge of the string, irrespective of the directionality of UI or text.
594 CGFloat glyphOffset = [layoutManager locationForGlyphAtIndex:glyphIndex].x;
596 CGFloat inputWidth = [attributedString size].width;
598 // The offset obtained above may need to be corrected because the left-most
599 // glyph may not have 0 offset. So we find the offset of left-most glyph, and
600 // subtract it from the offset of the glyph we obtained above.
601 CGFloat minOffset = glyphOffset;
603 // If content is RTL, we are interested in the right-edge of the glyph.
604 // Unfortunately the bounding rect computation methods from NSLayoutManager or
605 // NSFont don't work correctly with bidirectional text. So we compute the
606 // glyph width by finding the closest glyph offset to the right of the glyph
607 // we are looking for.
608 CGFloat glyphWidth = inputWidth;
610 for (NSUInteger i = 0; i < [attributedString length]; i++) {
611 if (i == charIndex) continue;
612 glyphIndex = [layoutManager glyphIndexForCharacterAtIndex:i];
613 CGFloat offset = [layoutManager locationForGlyphAtIndex:glyphIndex].x;
614 minOffset = std::min(minOffset, offset);
615 if (offset > glyphOffset)
616 glyphWidth = std::min(glyphWidth, offset - glyphOffset);
618 glyphOffset -= minOffset;
620 glyphWidth = inputWidth - glyphOffset;
622 glyphOffset += glyphWidth;
623 return base::i18n::IsRTL() ? (inputWidth - glyphOffset) : glyphOffset;
626 + (NSAttributedString*)createSeparatorString {
627 base::string16 raw_separator =
628 l10n_util::GetStringUTF16(IDS_AUTOCOMPLETE_MATCH_DESCRIPTION_SEPARATOR);
629 return CreateAttributedString(raw_separator, DimTextColor());