blob: 6c69364724db177da1ce4d112e8159e7ecd2e5a6 [file] [log] [blame]
// 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 "chrome/browser/ui/cocoa/omnibox/omnibox_popup_cell.h"
#include <stddef.h>
#include <algorithm>
#include <cmath>
#include "base/i18n/rtl.h"
#include "base/mac/foundation_util.h"
#include "base/mac/scoped_nsobject.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/strings/sys_string_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "chrome/browser/ui/cocoa/omnibox/omnibox_popup_view_mac.h"
#include "chrome/browser/ui/cocoa/omnibox/omnibox_view_mac.h"
#include "chrome/grit/generated_resources.h"
#include "components/omnibox/browser/omnibox_popup_model.h"
#include "components/omnibox/browser/suggestion_answer.h"
#include "skia/ext/skia_utils_mac.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/gfx/font.h"
namespace {
// How far to offset image column from the left.
const CGFloat kImageXOffset = 5.0;
// How far to offset image and text.
const CGFloat kPaddingOffset = 3.0;
// How far to offset the text column from the left.
const CGFloat kTextStartOffset = 28.0;
// Rounding radius of selection and hover background on popup items.
const CGFloat kCellRoundingRadius = 2.0;
// Flips the given |rect| in context of the given |frame|.
NSRect FlipIfRTL(NSRect rect, NSRect frame) {
DCHECK_LE(NSMinX(frame), NSMinX(rect));
DCHECK_GE(NSMaxX(frame), NSMaxX(rect));
if (base::i18n::IsRTL()) {
NSRect result = rect;
result.origin.x = NSMinX(frame) + (NSMaxX(frame) - NSMaxX(rect));
return result;
}
return rect;
}
NSColor* SelectedBackgroundColor() {
return [NSColor selectedControlColor];
}
NSColor* HoveredBackgroundColor() {
return [NSColor controlHighlightColor];
}
NSColor* ContentTextColor() {
return [NSColor blackColor];
}
NSColor* DimTextColor() {
return [NSColor darkGrayColor];
}
NSColor* PositiveTextColor() {
return skia::SkColorToCalibratedNSColor(SkColorSetRGB(0x0b, 0x80, 0x43));
}
NSColor* NegativeTextColor() {
return skia::SkColorToCalibratedNSColor(SkColorSetRGB(0xc5, 0x39, 0x29));
}
NSColor* URLTextColor() {
return [NSColor colorWithCalibratedRed:0.0 green:0.55 blue:0.0 alpha:1.0];
}
NSFont* FieldFont() {
return OmniboxViewMac::GetFieldFont(gfx::Font::NORMAL);
}
NSFont* BoldFieldFont() {
return OmniboxViewMac::GetFieldFont(gfx::Font::BOLD);
}
NSFont* LargeFont() {
return OmniboxViewMac::GetLargeFont(gfx::Font::NORMAL);
}
NSFont* LargeSuperscriptFont() {
NSFont* font = OmniboxViewMac::GetLargeFont(gfx::Font::NORMAL);
// Calculate a slightly smaller font. The ratio here is somewhat arbitrary.
// Proportions from 5/9 to 5/7 all look pretty good.
CGFloat size = [font pointSize] * 5.0 / 9.0;
NSFontDescriptor* descriptor = [font fontDescriptor];
return [NSFont fontWithDescriptor:descriptor size:size];
}
NSFont* SmallFont() {
return OmniboxViewMac::GetSmallFont(gfx::Font::NORMAL);
}
CGFloat GetContentAreaWidth(NSRect cellFrame) {
return NSWidth(cellFrame) - kTextStartOffset;
}
NSAttributedString* CreateAnswerStringHelper(const base::string16& text,
NSInteger style_type,
bool is_bold) {
NSDictionary* answer_style = nil;
switch (style_type) {
case SuggestionAnswer::ANSWER:
answer_style = @{
NSForegroundColorAttributeName : ContentTextColor(),
NSFontAttributeName : LargeFont()
};
break;
case SuggestionAnswer::HEADLINE:
answer_style = @{
NSForegroundColorAttributeName : DimTextColor(),
NSFontAttributeName : LargeFont()
};
break;
case SuggestionAnswer::TOP_ALIGNED:
answer_style = @{
NSForegroundColorAttributeName : DimTextColor(),
NSFontAttributeName : LargeSuperscriptFont(),
NSSuperscriptAttributeName : @1
};
break;
case SuggestionAnswer::DESCRIPTION:
answer_style = @{
NSForegroundColorAttributeName : DimTextColor(),
NSFontAttributeName : FieldFont()
};
break;
case SuggestionAnswer::DESCRIPTION_NEGATIVE:
answer_style = @{
NSForegroundColorAttributeName : NegativeTextColor(),
NSFontAttributeName : LargeSuperscriptFont()
};
break;
case SuggestionAnswer::DESCRIPTION_POSITIVE:
answer_style = @{
NSForegroundColorAttributeName : PositiveTextColor(),
NSFontAttributeName : LargeSuperscriptFont()
};
break;
case SuggestionAnswer::MORE_INFO:
answer_style = @{
NSForegroundColorAttributeName : DimTextColor(),
NSFontAttributeName : SmallFont()
};
break;
case SuggestionAnswer::SUGGESTION:
answer_style = @{
NSForegroundColorAttributeName : ContentTextColor(),
NSFontAttributeName : FieldFont()
};
break;
case SuggestionAnswer::SUGGESTION_POSITIVE:
answer_style = @{
NSForegroundColorAttributeName : PositiveTextColor(),
NSFontAttributeName : FieldFont()
};
break;
case SuggestionAnswer::SUGGESTION_NEGATIVE:
answer_style = @{
NSForegroundColorAttributeName : NegativeTextColor(),
NSFontAttributeName : FieldFont()
};
break;
case SuggestionAnswer::SUGGESTION_LINK:
answer_style = @{
NSForegroundColorAttributeName : URLTextColor(),
NSFontAttributeName : FieldFont()
};
break;
case SuggestionAnswer::STATUS:
answer_style = @{
NSForegroundColorAttributeName : DimTextColor(),
NSFontAttributeName : LargeSuperscriptFont()
};
break;
case SuggestionAnswer::PERSONALIZED_SUGGESTION:
answer_style = @{
NSForegroundColorAttributeName : ContentTextColor(),
NSFontAttributeName : FieldFont()
};
break;
}
if (is_bold) {
NSMutableDictionary* bold_style = [answer_style mutableCopy];
// TODO(dschuyler): Account for bolding fonts other than FieldFont.
// Field font is the only one currently necessary to bold.
[bold_style setObject:BoldFieldFont() forKey:NSFontAttributeName];
answer_style = bold_style;
}
return [[[NSAttributedString alloc]
initWithString:base::SysUTF16ToNSString(text)
attributes:answer_style] autorelease];
}
NSAttributedString* CreateAnswerString(const base::string16& text,
NSInteger style_type) {
// TODO(dschuyler): make this better. Right now this only supports unnested
// bold tags. In the future we'll need to flag unexpected tags while adding
// support for b, i, u, sub, and sup. We'll also need to support HTML
// entities (&lt; for '<', etc.).
const base::string16 begin_tag = base::ASCIIToUTF16("<b>");
const base::string16 end_tag = base::ASCIIToUTF16("</b>");
size_t begin = 0;
base::scoped_nsobject<NSMutableAttributedString> result(
[[NSMutableAttributedString alloc] init]);
while (true) {
size_t end = text.find(begin_tag, begin);
if (end == base::string16::npos) {
[result
appendAttributedString:CreateAnswerStringHelper(
text.substr(begin),
style_type, false)];
break;
}
[result appendAttributedString:CreateAnswerStringHelper(
text.substr(begin, end - begin),
style_type, false)];
begin = end + begin_tag.length();
end = text.find(end_tag, begin);
if (end == base::string16::npos)
break;
[result appendAttributedString:CreateAnswerStringHelper(
text.substr(begin, end - begin),
style_type, true)];
begin = end + end_tag.length();
}
return result.autorelease();
}
NSAttributedString* CreateAnswerLine(const SuggestionAnswer::ImageLine& line) {
base::scoped_nsobject<NSMutableAttributedString> answer_string(
[[NSMutableAttributedString alloc] init]);
DCHECK(!line.text_fields().empty());
for (const SuggestionAnswer::TextField& text_field : line.text_fields()) {
[answer_string
appendAttributedString:CreateAnswerString(text_field.text(),
text_field.type())];
}
const base::string16 space(base::ASCIIToUTF16(" "));
const SuggestionAnswer::TextField* text_field = line.additional_text();
if (text_field) {
[answer_string
appendAttributedString:CreateAnswerString(space + text_field->text(),
text_field->type())];
}
text_field = line.status_text();
if (text_field) {
[answer_string
appendAttributedString:CreateAnswerString(space + text_field->text(),
text_field->type())];
}
base::scoped_nsobject<NSMutableParagraphStyle> style(
[[NSMutableParagraphStyle alloc] init]);
[style setLineBreakMode:NSLineBreakByTruncatingTail];
[style setTighteningFactorForTruncation:0.0];
[answer_string addAttribute:NSParagraphStyleAttributeName
value:style
range:NSMakeRange(0, [answer_string length])];
return answer_string.autorelease();
}
NSMutableAttributedString* CreateAttributedString(
const base::string16& text,
NSColor* text_color,
NSTextAlignment textAlignment) {
// Start out with a string using the default style info.
NSString* s = base::SysUTF16ToNSString(text);
NSDictionary* attributes = @{
NSFontAttributeName : FieldFont(),
NSForegroundColorAttributeName : text_color
};
NSMutableAttributedString* attributedString = [[
[NSMutableAttributedString alloc] initWithString:s
attributes:attributes] autorelease];
NSMutableParagraphStyle* style =
[[[NSMutableParagraphStyle alloc] init] autorelease];
[style setLineBreakMode:NSLineBreakByTruncatingTail];
[style setTighteningFactorForTruncation:0.0];
[style setAlignment:textAlignment];
[attributedString addAttribute:NSParagraphStyleAttributeName
value:style
range:NSMakeRange(0, [attributedString length])];
return attributedString;
}
NSMutableAttributedString* CreateAttributedString(
const base::string16& text,
NSColor* text_color) {
return CreateAttributedString(text, text_color, NSNaturalTextAlignment);
}
NSAttributedString* CreateClassifiedAttributedString(
const base::string16& text,
NSColor* text_color,
const ACMatchClassifications& classifications) {
NSMutableAttributedString* attributedString =
CreateAttributedString(text, text_color);
NSUInteger match_length = [attributedString length];
// Mark up the runs which differ from the default.
for (ACMatchClassifications::const_iterator i = classifications.begin();
i != classifications.end(); ++i) {
const bool is_last = ((i + 1) == classifications.end());
const NSUInteger next_offset =
(is_last ? match_length : static_cast<NSUInteger>((i + 1)->offset));
const NSUInteger location = static_cast<NSUInteger>(i->offset);
const NSUInteger length = next_offset - static_cast<NSUInteger>(i->offset);
// Guard against bad, off-the-end classification ranges.
if (location >= match_length || length <= 0)
break;
const NSRange range =
NSMakeRange(location, std::min(length, match_length - location));
if (0 != (i->style & ACMatchClassification::MATCH)) {
[attributedString addAttribute:NSFontAttributeName
value:BoldFieldFont()
range:range];
}
if (0 != (i->style & ACMatchClassification::URL)) {
[attributedString addAttribute:NSForegroundColorAttributeName
value:URLTextColor()
range:range];
} else if (0 != (i->style & ACMatchClassification::DIM)) {
[attributedString addAttribute:NSForegroundColorAttributeName
value:DimTextColor()
range:range];
}
}
return attributedString;
}
} // namespace
@interface OmniboxPopupCell ()
- (CGFloat)drawMatchPart:(NSAttributedString*)attributedString
withFrame:(NSRect)cellFrame
origin:(NSPoint)origin
withMaxWidth:(int)maxWidth;
- (CGFloat)drawMatchPrefixWithFrame:(NSRect)cellFrame
tableView:(OmniboxPopupMatrix*)tableView
withContentsMaxWidth:(int*)contentsMaxWidth;
- (void)drawMatchWithFrame:(NSRect)cellFrame inView:(NSView*)controlView;
@end
@implementation OmniboxPopupCellData
@synthesize contents = contents_;
@synthesize description = description_;
@synthesize prefix = prefix_;
@synthesize image = image_;
@synthesize answerImage = answerImage_;
@synthesize contentsOffset = contentsOffset_;
@synthesize isContentsRTL = isContentsRTL_;
@synthesize isAnswer = isAnswer_;
@synthesize matchType = matchType_;
- (instancetype)initWithMatch:(const AutocompleteMatch&)match
contentsOffset:(CGFloat)contentsOffset
image:(NSImage*)image
answerImage:(NSImage*)answerImage {
if ((self = [super init])) {
image_ = [image retain];
answerImage_ = [answerImage retain];
contentsOffset_ = contentsOffset;
isContentsRTL_ =
(base::i18n::RIGHT_TO_LEFT ==
base::i18n::GetFirstStrongCharacterDirection(match.contents));
matchType_ = match.type;
// Prefix may not have any characters with strong directionality, and may
// take the UI directionality. But prefix needs to appear in continuation
// of the contents so we force the directionality.
NSTextAlignment textAlignment =
isContentsRTL_ ? NSRightTextAlignment : NSLeftTextAlignment;
prefix_ =
[CreateAttributedString(base::UTF8ToUTF16(match.GetAdditionalInfo(
kACMatchPropertyContentsPrefix)),
ContentTextColor(), textAlignment) retain];
isAnswer_ = match.answer;
if (isAnswer_) {
contents_ = [CreateAnswerLine(match.answer->first_line()) retain];
description_ = [CreateAnswerLine(match.answer->second_line()) retain];
} else {
contents_ = [CreateClassifiedAttributedString(
match.contents, ContentTextColor(), match.contents_class) retain];
if (!match.description.empty()) {
description_ = [CreateClassifiedAttributedString(
match.description, DimTextColor(), match.description_class) retain];
}
}
}
return self;
}
- (instancetype)copyWithZone:(NSZone*)zone {
return [self retain];
}
- (CGFloat)getMatchContentsWidth {
return [contents_ size].width;
}
@end
@implementation OmniboxPopupCell
- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView {
if ([self state] == NSOnState || [self isHighlighted]) {
if ([self state] == NSOnState)
[SelectedBackgroundColor() set];
else
[HoveredBackgroundColor() set];
NSBezierPath* path =
[NSBezierPath bezierPathWithRoundedRect:cellFrame
xRadius:kCellRoundingRadius
yRadius:kCellRoundingRadius];
[path fill];
}
[self drawMatchWithFrame:cellFrame inView:controlView];
}
- (void)drawMatchWithFrame:(NSRect)cellFrame inView:(NSView*)controlView {
OmniboxPopupCellData* cellData =
base::mac::ObjCCastStrict<OmniboxPopupCellData>([self objectValue]);
OmniboxPopupMatrix* tableView =
base::mac::ObjCCastStrict<OmniboxPopupMatrix>(controlView);
CGFloat remainingWidth = GetContentAreaWidth(cellFrame);
CGFloat contentsWidth = [cellData getMatchContentsWidth];
CGFloat separatorWidth = [[tableView separator] size].width;
CGFloat descriptionWidth =
[cellData description] ? [[cellData description] size].width : 0;
int contentsMaxWidth, descriptionMaxWidth;
OmniboxPopupModel::ComputeMatchMaxWidths(
ceilf(contentsWidth), ceilf(separatorWidth), ceilf(descriptionWidth),
ceilf(remainingWidth),
!AutocompleteMatch::IsSearchType([cellData matchType]), &contentsMaxWidth,
&descriptionMaxWidth);
NSRect imageRect = cellFrame;
imageRect.size = [[cellData image] size];
imageRect.origin.x += kImageXOffset;
imageRect.origin.y += kPaddingOffset;
[[cellData image] drawInRect:FlipIfRTL(imageRect, cellFrame)
fromRect:NSZeroRect
operation:NSCompositeSourceOver
fraction:1.0
respectFlipped:YES
hints:nil];
NSPoint origin = NSMakePoint(kTextStartOffset, kPaddingOffset);
if ([cellData matchType] == AutocompleteMatchType::SEARCH_SUGGEST_TAIL) {
// Infinite suggestions are rendered with a prefix (usually ellipsis), which
// appear vertically stacked.
origin.x += [self drawMatchPrefixWithFrame:cellFrame
tableView:tableView
withContentsMaxWidth:&contentsMaxWidth];
}
origin.x += [self drawMatchPart:[cellData contents]
withFrame:cellFrame
origin:origin
withMaxWidth:contentsMaxWidth];
if (descriptionMaxWidth > 0) {
if ([cellData isAnswer]) {
origin =
NSMakePoint(kTextStartOffset, kContentLineHeight - kPaddingOffset);
CGFloat imageSize = [tableView answerLineHeight];
NSRect imageRect =
NSMakeRect(NSMinX(cellFrame) + origin.x, NSMinY(cellFrame) + origin.y,
imageSize, imageSize);
[[cellData answerImage] drawInRect:FlipIfRTL(imageRect, cellFrame)
fromRect:NSZeroRect
operation:NSCompositeSourceOver
fraction:1.0
respectFlipped:YES
hints:nil];
if ([cellData answerImage])
origin.x += imageSize + kPaddingOffset;
} else {
origin.x += [self drawMatchPart:[tableView separator]
withFrame:cellFrame
origin:origin
withMaxWidth:separatorWidth];
}
origin.x += [self drawMatchPart:[cellData description]
withFrame:cellFrame
origin:origin
withMaxWidth:descriptionMaxWidth];
}
}
- (CGFloat)drawMatchPrefixWithFrame:(NSRect)cellFrame
tableView:(OmniboxPopupMatrix*)tableView
withContentsMaxWidth:(int*)contentsMaxWidth {
OmniboxPopupCellData* cellData =
base::mac::ObjCCastStrict<OmniboxPopupCellData>([self objectValue]);
CGFloat offset = 0.0f;
CGFloat remainingWidth = GetContentAreaWidth(cellFrame);
CGFloat prefixWidth = [[cellData prefix] size].width;
CGFloat prefixOffset = 0.0f;
if (base::i18n::IsRTL() != [cellData isContentsRTL]) {
// The contents is rendered between the contents offset extending towards
// the start edge, while prefix is rendered in opposite direction. Ideally
// the prefix should be rendered at |contentsOffset_|. If that is not
// sufficient to render the widest suggestion, we increase it to
// |maxMatchContentsWidth|. If |remainingWidth| is not sufficient to
// accommodate that, we reduce the offset so that the prefix gets rendered.
prefixOffset = std::min(
remainingWidth - prefixWidth,
std::max([cellData contentsOffset], [tableView maxMatchContentsWidth]));
offset = std::max<CGFloat>(0.0, prefixOffset - *contentsMaxWidth);
} else { // The direction of contents is same as UI direction.
// Ideally the offset should be |contentsOffset_|. If the max total width
// (|prefixWidth| + |maxMatchContentsWidth|) from offset will exceed the
// |remainingWidth|, then we shift the offset to the left , so that all
// postfix suggestions are visible.
// We have to render the prefix, so offset has to be at least |prefixWidth|.
offset =
std::max(prefixWidth,
std::min(remainingWidth - [tableView maxMatchContentsWidth],
[cellData contentsOffset]));
prefixOffset = offset - prefixWidth;
}
*contentsMaxWidth = std::min((int)ceilf(remainingWidth - prefixWidth),
*contentsMaxWidth);
[self drawMatchPart:[cellData prefix]
withFrame:cellFrame
origin:NSMakePoint(prefixOffset + kTextStartOffset, 0)
withMaxWidth:prefixWidth];
return offset;
}
- (CGFloat)drawMatchPart:(NSAttributedString*)attributedString
withFrame:(NSRect)cellFrame
origin:(NSPoint)origin
withMaxWidth:(int)maxWidth {
NSRect renderRect = NSIntersectionRect(
cellFrame, NSOffsetRect(cellFrame, origin.x, origin.y));
renderRect.size.width =
std::min(NSWidth(renderRect), static_cast<CGFloat>(maxWidth));
if (!NSIsEmptyRect(renderRect))
[attributedString drawInRect:FlipIfRTL(renderRect, cellFrame)];
return NSWidth(renderRect);
}
+ (CGFloat)computeContentsOffset:(const AutocompleteMatch&)match {
const base::string16& inputText = base::UTF8ToUTF16(
match.GetAdditionalInfo(kACMatchPropertyInputText));
int contentsStartIndex = 0;
base::StringToInt(
match.GetAdditionalInfo(kACMatchPropertyContentsStartIndex),
&contentsStartIndex);
// Ignore invalid state.
if (!base::StartsWith(match.fill_into_edit, inputText,
base::CompareCase::SENSITIVE) ||
!base::EndsWith(match.fill_into_edit, match.contents,
base::CompareCase::SENSITIVE) ||
((size_t)contentsStartIndex >= inputText.length())) {
return 0;
}
bool isContentsRTL = (base::i18n::RIGHT_TO_LEFT ==
base::i18n::GetFirstStrongCharacterDirection(match.contents));
// Color does not matter.
NSAttributedString* attributedString =
CreateAttributedString(inputText, DimTextColor());
base::scoped_nsobject<NSTextStorage> textStorage(
[[NSTextStorage alloc] initWithAttributedString:attributedString]);
base::scoped_nsobject<NSLayoutManager> layoutManager(
[[NSLayoutManager alloc] init]);
base::scoped_nsobject<NSTextContainer> textContainer(
[[NSTextContainer alloc] init]);
[layoutManager addTextContainer:textContainer];
[textStorage addLayoutManager:layoutManager];
NSUInteger charIndex = static_cast<NSUInteger>(contentsStartIndex);
NSUInteger glyphIndex =
[layoutManager glyphIndexForCharacterAtIndex:charIndex];
// This offset is computed from the left edge of the glyph always from the
// left edge of the string, irrespective of the directionality of UI or text.
CGFloat glyphOffset = [layoutManager locationForGlyphAtIndex:glyphIndex].x;
CGFloat inputWidth = [attributedString size].width;
// The offset obtained above may need to be corrected because the left-most
// glyph may not have 0 offset. So we find the offset of left-most glyph, and
// subtract it from the offset of the glyph we obtained above.
CGFloat minOffset = glyphOffset;
// If content is RTL, we are interested in the right-edge of the glyph.
// Unfortunately the bounding rect computation methods from NSLayoutManager or
// NSFont don't work correctly with bidirectional text. So we compute the
// glyph width by finding the closest glyph offset to the right of the glyph
// we are looking for.
CGFloat glyphWidth = inputWidth;
for (NSUInteger i = 0; i < [attributedString length]; i++) {
if (i == charIndex) continue;
glyphIndex = [layoutManager glyphIndexForCharacterAtIndex:i];
CGFloat offset = [layoutManager locationForGlyphAtIndex:glyphIndex].x;
minOffset = std::min(minOffset, offset);
if (offset > glyphOffset)
glyphWidth = std::min(glyphWidth, offset - glyphOffset);
}
glyphOffset -= minOffset;
if (glyphWidth == 0)
glyphWidth = inputWidth - glyphOffset;
if (isContentsRTL)
glyphOffset += glyphWidth;
return base::i18n::IsRTL() ? (inputWidth - glyphOffset) : glyphOffset;
}
+ (NSAttributedString*)createSeparatorString {
base::string16 raw_separator =
l10n_util::GetStringUTF16(IDS_AUTOCOMPLETE_MATCH_DESCRIPTION_SEPARATOR);
return CreateAttributedString(raw_separator, DimTextColor());
}
@end