// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/ui/omnibox/popup/autocomplete_match_formatter.h"
#import <UIKit/UIKit.h>
#import "base/metrics/field_trial_params.h"
#import "base/strings/sys_string_conversions.h"
#import "base/strings/utf_string_conversions.h"
#import "components/omnibox/browser/actions/omnibox_action_in_suggest.h"
#import "components/omnibox/browser/autocomplete_match.h"
#import "components/omnibox/browser/autocomplete_provider.h"
#import "components/omnibox/browser/omnibox_feature_configs.h"
#import "components/omnibox/browser/suggestion_answer.h"
#import "ios/chrome/browser/net/model/crurl.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_ui_features.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_util.h"
#import "ios/chrome/browser/ui/omnibox/popup/omnibox_icon_formatter.h"
#import "ios/chrome/browser/ui/omnibox/popup/popup_swift.h"
#import "ios/chrome/browser/ui/omnibox/popup/row/actions/suggest_action.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
namespace {
/// The color of the main text of a suggest cell.
UIColor* SuggestionTextColor() {
return [UIColor colorNamed:kTextPrimaryColor];
}
/// The color of the detail text of a suggest cell.
UIColor* SuggestionDetailTextColor() {
return [UIColor colorNamed:kTextSecondaryColor];
}
/// The color of the text in the portion of a search suggestion that matches the
/// omnibox input text.
UIColor* DimColor() {
return [UIColor colorWithWhite:(161 / 255.0) alpha:1.0];
}
UIColor* DimColorIncognito() {
return UIColor.whiteColor;
}
} // namespace
@implementation AutocompleteMatchFormatter {
AutocompleteMatch _match;
}
@synthesize suggestionSectionId;
@synthesize actionsInSuggest;
- (instancetype)initWithMatch:(const AutocompleteMatch&)match {
self = [super init];
if (self) {
_match = AutocompleteMatch(match);
}
return self;
}
+ (instancetype)formatterWithMatch:(const AutocompleteMatch&)match {
return [[self alloc] initWithMatch:match];
}
+ (NSAttributedString*)spacerAttributedString {
return [[NSAttributedString alloc] initWithString:@" "];
}
#pragma mark - NSObject
- (NSString*)description {
NSString* description = [NSString
stringWithFormat:@"<%@ %p> %@ (%@)", self.class, self,
self.text.string, self.detailText.string];
if (self.pedalData) {
description =
[description stringByAppendingFormat:@" P:[%@]", self.pedalData.title];
}
return description;
}
#pragma mark AutocompleteSuggestion
- (BOOL)supportsDeletion {
return _match.SupportsDeletion();
}
- (BOOL)hasAnswer {
if (omnibox_feature_configs::SuggestionAnswerMigration::Get().enabled) {
return _match.answer_template.has_value();
}
return _match.answer.has_value();
}
- (BOOL)isURL {
return !AutocompleteMatch::IsSearchType(_match.type);
}
- (NSAttributedString*)detailText {
if (self.hasAnswer) {
return [self answerDetailText];
} else {
// The detail text should be the URL (`_match.contents`) for non-search
// suggestions and the entity type (`_match.description`) for search entity
// suggestions. For all other search suggestions, `_match.description` is
// the name of the currently selected search engine, which for mobile we
// suppress.
NSString* detailText = nil;
if (self.isURL) {
detailText = base::SysUTF16ToNSString(_match.contents);
} else if (_match.type == AutocompleteMatchType::SEARCH_SUGGEST_ENTITY) {
detailText = base::SysUTF16ToNSString(_match.description);
}
if (!detailText.length) {
return nil;
}
const ACMatchClassifications* classifications =
self.isURL ? &_match.contents_class : nullptr;
// The suggestion detail color should match the main text color for entity
// suggestions. For non-search suggestions (URLs), a highlight color is used
// instead.
UIColor* suggestionDetailTextColor = nil;
if (_match.type != AutocompleteMatchType::SEARCH_SUGGEST_ENTITY) {
suggestionDetailTextColor = SuggestionDetailTextColor();
} else {
suggestionDetailTextColor = SuggestionTextColor();
}
DCHECK(suggestionDetailTextColor);
return [self attributedStringWithString:detailText
classifications:classifications
smallFont:YES
color:suggestionDetailTextColor
dimColor:DimColor()];
}
}
- (NSAttributedString*)answerDetailText {
DCHECK(self.hasAnswer);
if (omnibox_feature_configs::SuggestionAnswerMigration::Get().enabled) {
NSMutableAttributedString* result =
[[NSMutableAttributedString alloc] initWithString:@""];
NSAttributedString* spacer = [[self class] spacerAttributedString];
if (_match.answer_type == omnibox::ANSWER_TYPE_DICTIONARY) {
auto subheadFragments =
_match.answer_template->answers(0).subhead().fragments();
for (auto fragment : subheadFragments) {
NSAttributedString* fragmentText =
[self attributedStringForFragment:fragment
color:SuggestionDetailTextColor()
useDeemphasizedStyling:YES];
[result appendAttributedString:fragmentText];
[result appendAttributedString:spacer];
}
} else {
auto headLineFragments =
_match.answer_template->answers(0).headline().fragments();
for (NSInteger i = 0; i < headLineFragments.size(); i++) {
NSAttributedString* fragmentText;
// The first fragment has a html bold tag so we skip it and use the
// match contents instead. The match contents has the first fragment
// text without the bold tag (eg. match contents : abc , first fragment
// : ab<b>c</b>).
// TODO(crbug.com/343706167): Follow up on removing the bold tag from
// the first fragment.
if (i == 0) {
fragmentText =
[self attributedStringWithString:base::SysUTF16ToNSString(
_match.contents)
classifications:&_match.contents_class
smallFont:NO
color:SuggestionDetailTextColor()
dimColor:DimColor()];
} else {
fragmentText =
[self attributedStringForFragment:headLineFragments[i]
color:SuggestionDetailTextColor()
useDeemphasizedStyling:YES];
}
[result appendAttributedString:fragmentText];
[result appendAttributedString:spacer];
}
}
// Remove the extra spacer.
if (result.length > 0) {
NSRange lastCharacterRange =
NSMakeRange(result.length - spacer.length, spacer.length);
[result deleteCharactersInRange:lastCharacterRange];
}
return result;
} else {
if (!_match.answer->IsExceptedFromLineReversal(_match.answer_type)) {
NSAttributedString* detailBaseText = [self
attributedStringWithString:base::SysUTF16ToNSString(_match.contents)
classifications:&_match.contents_class
smallFont:NO
color:SuggestionDetailTextColor()
dimColor:DimColor()];
return [self addExtraTextFromAnswerLine:_match.answer->first_line()
toAttributedString:detailBaseText
useDeemphasizedStyling:YES];
}
return [self attributedStringWithAnswerLine:_match.answer->second_line()
useDeemphasizedStyling:YES];
}
}
- (id<OmniboxIcon>)icon {
OmniboxIconFormatter* icon =
[[OmniboxIconFormatter alloc] initWithMatch:_match];
icon.defaultSearchEngineIsGoogle = self.defaultSearchEngineIsGoogle;
return icon;
}
- (NSInteger)numberOfLines {
if (omnibox_feature_configs::SuggestionAnswerMigration::Get().enabled) {
return _match.answer_type == omnibox::ANSWER_TYPE_DICTIONARY ? 3 : 1;
}
if (_match.answer->second_line().text_fields().empty()) {
return 1;
}
// Answers specify their own limit on the number of lines to show but are
// additionally capped here at 3 to guard against unreasonable values.
const SuggestionAnswer::TextField& first_text_field =
_match.answer->second_line().text_fields()[0];
if (first_text_field.has_num_lines() && first_text_field.num_lines() > 1)
return MIN(3, first_text_field.num_lines());
else
return 1;
}
- (NSNumber*)suggestionGroupId {
if (!_match.suggestion_group_id.has_value()) {
return nil;
}
return [NSNumber
numberWithInt:static_cast<int>(_match.suggestion_group_id.value())];
}
- (NSAttributedString*)text {
if (self.hasAnswer) {
return [self answerText];
} else {
// The text should be search term (`_match.contents`) for searches,
// otherwise page title (`_match.description`).
std::u16string textString =
self.isURL ? _match.description : _match.contents;
// Clipboard suggestion "Text you copied" text is stored in description.
// The content is empty as iOS doesn't access the clipboard when creating
// the match.
if (_match.type == AutocompleteMatchType::CLIPBOARD_TEXT ||
_match.type == AutocompleteMatchType::CLIPBOARD_IMAGE) {
textString = _match.description;
}
NSString* text = base::SysUTF16ToNSString(textString);
// If for some reason the title is empty, copy the detailText.
if ([text length] == 0 && [self.detailText length] != 0) {
text = [self.detailText string];
}
const ACMatchClassifications* textClassifications =
!self.isURL ? &_match.contents_class : &_match.description_class;
UIColor* suggestionTextColor = SuggestionTextColor();
UIColor* dimColor = self.incognito ? DimColorIncognito() : DimColor();
NSAttributedString* attributedText =
[self attributedStringWithString:text
classifications:textClassifications
smallFont:NO
color:suggestionTextColor
dimColor:dimColor];
if (self.isTailSuggestion) {
NSMutableAttributedString* mutableString =
[[NSMutableAttributedString alloc] init];
NSAttributedString* tailSuggestPrefix =
// TODO(crbug.com/40264215): Do we want to localize the ellipsis ?
[self attributedStringWithString:@"... "
classifications:NULL
smallFont:NO
color:suggestionTextColor
dimColor:dimColor];
[mutableString appendAttributedString:tailSuggestPrefix];
[mutableString appendAttributedString:attributedText];
attributedText = mutableString;
}
return attributedText;
}
}
- (NSAttributedString*)answerText {
DCHECK(self.hasAnswer);
UIColor* suggestionTextColor = SuggestionTextColor();
UIColor* dimColor = self.incognito ? DimColorIncognito() : DimColor();
if (omnibox_feature_configs::SuggestionAnswerMigration::Get().enabled) {
NSMutableAttributedString* result =
[[NSMutableAttributedString alloc] initWithString:@""];
NSAttributedString* spacer = [[self class] spacerAttributedString];
if (_match.answer_type == omnibox::ANSWER_TYPE_DICTIONARY) {
auto headlineFragments =
_match.answer_template->answers(0).headline().fragments();
for (NSInteger i = 0; i < headlineFragments.size(); i++) {
NSAttributedString* fragmentText;
// The first fragment has a html bold tag so we skip it and use the
// match contents instead. The match contents has the first fragment
// text without the bold tag (eg. match contents : abc , first fragment
// : ab<b>c</b>).
// TODO(crbug.com/343706167): Follow up on removing the bold tag from
// the first fragment.
if (i == 0) {
fragmentText =
[self attributedStringWithString:base::SysUTF16ToNSString(
_match.contents)
classifications:&_match.contents_class
smallFont:NO
color:suggestionTextColor
dimColor:dimColor];
} else {
fragmentText =
[self attributedStringForFragment:headlineFragments[i]
color:SuggestionDetailTextColor()
useDeemphasizedStyling:NO];
}
[result appendAttributedString:fragmentText];
[result appendAttributedString:spacer];
}
} else {
auto subheadFragments =
_match.answer_template->answers(0).subhead().fragments();
for (auto fragment : subheadFragments) {
NSAttributedString* fragmentText =
[self attributedStringForFragment:fragment
color:SuggestionDetailTextColor()
useDeemphasizedStyling:NO];
[result appendAttributedString:fragmentText];
[result appendAttributedString:spacer];
}
}
// Remove the extra spacer.
if (result.length > 0) {
NSRange lastCharacterRange =
NSMakeRange(result.length - spacer.length, spacer.length);
[result deleteCharactersInRange:lastCharacterRange];
}
return result;
} else {
if (!_match.answer->IsExceptedFromLineReversal(_match.answer_type)) {
return [self attributedStringWithAnswerLine:_match.answer->second_line()
useDeemphasizedStyling:NO];
} else {
NSAttributedString* attributedBaseText = [self
attributedStringWithString:base::SysUTF16ToNSString(_match.contents)
classifications:&_match.contents_class
smallFont:NO
color:suggestionTextColor
dimColor:dimColor];
return [self addExtraTextFromAnswerLine:_match.answer->first_line()
toAttributedString:attributedBaseText
useDeemphasizedStyling:NO];
}
}
}
- (NSAttributedString*)omniboxPreviewText {
return [[NSAttributedString alloc]
initWithString:base::SysUTF16ToNSString(_match.fill_into_edit)];
}
/// The primary purpose of this list is to omit the "what you typed" types,
/// since those are simply the input in the omnibox and copying the text back to
/// the omnibox would be a noop. However, this list also omits other types that
/// are deprecated or not launched on iOS.
- (BOOL)isAppendable {
return _match.type == AutocompleteMatchType::BOOKMARK_TITLE ||
_match.type == AutocompleteMatchType::CALCULATOR ||
_match.type == AutocompleteMatchType::HISTORY_BODY ||
_match.type == AutocompleteMatchType::HISTORY_CLUSTER ||
_match.type == AutocompleteMatchType::HISTORY_KEYWORD ||
_match.type == AutocompleteMatchType::HISTORY_TITLE ||
_match.type == AutocompleteMatchType::HISTORY_URL ||
_match.type == AutocompleteMatchType::NAVSUGGEST ||
_match.type == AutocompleteMatchType::NAVSUGGEST_PERSONALIZED ||
_match.type == AutocompleteMatchType::PHYSICAL_WEB_DEPRECATED ||
_match.type == AutocompleteMatchType::SEARCH_HISTORY ||
_match.type == AutocompleteMatchType::SEARCH_SUGGEST ||
_match.type == AutocompleteMatchType::SEARCH_SUGGEST_ENTITY ||
_match.type == AutocompleteMatchType::SEARCH_SUGGEST_PERSONALIZED ||
_match.type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL ||
_match.type == AutocompleteMatchType::STARTER_PACK;
}
- (BOOL)isTabMatch {
return _match.has_tab_match.value_or(false);
}
- (id<OmniboxPedal>)pedal {
return self.pedalData;
}
- (UIImage*)matchTypeIcon {
return GetOmniboxSuggestionIconForAutocompleteMatchType(_match.type);
}
- (NSString*)matchTypeIconAccessibilityIdentifier {
return base::SysUTF8ToNSString(AutocompleteMatchType::ToString(_match.type));
}
- (BOOL)isMatchTypeSearch {
return AutocompleteMatch::IsSearchType(_match.type);
}
- (BOOL)isWrapping {
return self.isMatchTypeSearch && !self.hasAnswer &&
_match.type != AutocompleteMatchType::SEARCH_SUGGEST_ENTITY;
}
- (CrURL*)destinationUrl {
return [[CrURL alloc] initWithGURL:_match.destination_url];
}
- (const AutocompleteMatch&)autocompleteMatch {
return _match;
}
#pragma mark tail suggest
- (BOOL)isTailSuggestion {
return _match.type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL;
}
- (NSString*)commonPrefix {
if (!self.isTailSuggestion) {
return @"";
}
return base::SysUTF16ToNSString(_match.tail_suggest_common_prefix);
}
#pragma mark helpers
/// Create a string to display for an entire answer line.
- (NSAttributedString*)
attributedStringWithAnswerLine:(const SuggestionAnswer::ImageLine&)line
useDeemphasizedStyling:(BOOL)useDeemphasizedStyling {
NSMutableAttributedString* result =
[[NSMutableAttributedString alloc] initWithString:@""];
for (const auto& field : line.text_fields()) {
[result appendAttributedString:
[self attributedStringForTextfield:&field
useDeemphasizedStyling:useDeemphasizedStyling]];
}
return [self addExtraTextFromAnswerLine:line
toAttributedString:result
useDeemphasizedStyling:useDeemphasizedStyling];
}
/// Adds the `additional_text` and `status_text` from `line` to the given
/// attributed string. This is necessary because answers get their main text
/// from the match contents instead of the ImageLine's text_fields. This is
/// because those fields contain server-provided formatting, which aren't used.
- (NSAttributedString*)
addExtraTextFromAnswerLine:(const SuggestionAnswer::ImageLine&)line
toAttributedString:(NSAttributedString*)attributedString
useDeemphasizedStyling:(BOOL)useDeemphasizedStyling {
NSMutableAttributedString* result = [attributedString mutableCopy];
if (line.additional_text() != nil) {
[result appendAttributedString:[[self class] spacerAttributedString]];
NSAttributedString* extra =
[self attributedStringForTextfield:line.additional_text()
useDeemphasizedStyling:useDeemphasizedStyling];
[result appendAttributedString:extra];
}
if (line.status_text() != nil) {
[result appendAttributedString:[[self class] spacerAttributedString]];
[result appendAttributedString:
[self attributedStringForTextfield:line.status_text()
useDeemphasizedStyling:useDeemphasizedStyling]];
}
return result;
}
/// Create a string to display for a textual part ("textfield") of a suggestion
/// answer.
- (NSAttributedString*)
attributedStringForTextfield:(const SuggestionAnswer::TextField*)field
useDeemphasizedStyling:(BOOL)useDeemphasizedStyling {
const std::u16string& string = field->text();
NSString* unescapedString =
base::SysUTF16ToNSString(base::UnescapeForHTML(string));
NSDictionary* attributes =
[self formattingAttributesForSuggestionStyle:field->style()
useDeemphasizedStyling:useDeemphasizedStyling];
return [[NSAttributedString alloc] initWithString:unescapedString
attributes:attributes];
}
#pragma mark FormattedStringFragment styling
// Converts an attributed string fragment proto into an attributedString
- (NSAttributedString*)
attributedStringForFragment:
(omnibox::FormattedString::FormattedStringFragment)fragment
color:(UIColor*)defaultColor
useDeemphasizedStyling:(BOOL)useDeemphasizedStyling {
NSDictionary* attributes =
[self formattingAttributesForFragment:fragment
useDeemphasizedStyling:useDeemphasizedStyling];
NSAttributedString* result = [[NSAttributedString alloc]
initWithString:base::SysUTF8ToNSString(fragment.text())
attributes:attributes];
return result;
}
/// Return correct formatting attributes for the fragment proto.
/// `useDeemphasizedStyling` is necessary because some styles (e.g. PRIMARY)
/// should take their color from the surrounding line; they don't have a fixed
/// color.
- (NSDictionary<NSAttributedStringKey, id>*)
formattingAttributesForFragment:
(omnibox::FormattedString::FormattedStringFragment)fragment
useDeemphasizedStyling:(BOOL)useDeemphasizedStyling {
UIFontDescriptor* defaultFontDescriptor =
useDeemphasizedStyling
? [[UIFontDescriptor
preferredFontDescriptorWithTextStyle:UIFontTextStyleSubheadline]
fontDescriptorWithSymbolicTraits:
UIFontDescriptorTraitTightLeading]
: [UIFontDescriptor
preferredFontDescriptorWithTextStyle:UIFontTextStyleBody];
UIColor* defaultColor = useDeemphasizedStyling ? SuggestionDetailTextColor()
: SuggestionTextColor();
omnibox::FormattedString::ColorType color = fragment.color();
switch (color) {
case omnibox::FormattedString::COLOR_ON_SURFACE_POSITIVE:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : [UIColor colorNamed:kGreenColor],
};
case omnibox::FormattedString::COLOR_ON_SURFACE_NEGATIVE:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : [UIColor colorNamed:kRedColor],
};
case omnibox::FormattedString::COLOR_PRIMARY: {
// Calculate a slightly smaller font. The ratio here is somewhat
// arbitrary. Proportions from 5/9 to 5/7 all look pretty good.
CGFloat ratio = 5.0 / 9.0;
UIFont* defaultFont = [UIFont fontWithDescriptor:defaultFontDescriptor
size:0];
UIFontDescriptor* superiorFontDescriptor = [defaultFontDescriptor
fontDescriptorWithSize:defaultFontDescriptor.pointSize * ratio];
CGFloat baselineOffset =
defaultFont.capHeight - defaultFont.capHeight * ratio;
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:superiorFontDescriptor
size:0],
NSBaselineOffsetAttributeName :
[NSNumber numberWithFloat:baselineOffset],
NSForegroundColorAttributeName : defaultColor,
};
}
default:
BOOL isFinanceDetailText =
_match.answer_type == omnibox::ANSWER_TYPE_FINANCE &&
useDeemphasizedStyling;
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : isFinanceDetailText ? UIColor.grayColor
: defaultColor,
};
}
}
/// Return correct formatting attributes for the given style.
/// `useDeemphasizedStyling` is necessary because some styles (e.g. SUPERIOR)
/// should take their color from the surrounding line; they don't have a fixed
/// color.
- (NSDictionary<NSAttributedStringKey, id>*)
formattingAttributesForSuggestionStyle:(SuggestionAnswer::TextStyle)style
useDeemphasizedStyling:(BOOL)useDeemphasizedStyling {
UIFontDescriptor* defaultFontDescriptor =
useDeemphasizedStyling
? [[UIFontDescriptor
preferredFontDescriptorWithTextStyle:UIFontTextStyleSubheadline]
fontDescriptorWithSymbolicTraits:
UIFontDescriptorTraitTightLeading]
: [UIFontDescriptor
preferredFontDescriptorWithTextStyle:UIFontTextStyleBody];
UIColor* defaultColor = useDeemphasizedStyling ? SuggestionDetailTextColor()
: SuggestionTextColor();
switch (style) {
case SuggestionAnswer::TextStyle::NORMAL:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : defaultColor,
};
case SuggestionAnswer::TextStyle::NORMAL_DIM:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : UIColor.grayColor,
};
case SuggestionAnswer::TextStyle::SECONDARY:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : UIColor.grayColor,
};
case SuggestionAnswer::TextStyle::BOLD: {
UIFontDescriptor* boldFontDescriptor = [defaultFontDescriptor
fontDescriptorWithSymbolicTraits:UIFontDescriptorTraitBold];
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:boldFontDescriptor
size:0],
NSForegroundColorAttributeName : defaultColor,
};
}
case SuggestionAnswer::TextStyle::POSITIVE:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : [UIColor colorNamed:kGreenColor],
};
case SuggestionAnswer::TextStyle::NEGATIVE:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : [UIColor colorNamed:kRedColor],
};
case SuggestionAnswer::TextStyle::SUPERIOR: {
// Calculate a slightly smaller font. The ratio here is somewhat
// arbitrary. Proportions from 5/9 to 5/7 all look pretty good.
CGFloat ratio = 5.0 / 9.0;
UIFont* defaultFont = [UIFont fontWithDescriptor:defaultFontDescriptor
size:0];
UIFontDescriptor* superiorFontDescriptor = [defaultFontDescriptor
fontDescriptorWithSize:defaultFontDescriptor.pointSize * ratio];
CGFloat baselineOffset =
defaultFont.capHeight - defaultFont.capHeight * ratio;
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:superiorFontDescriptor
size:0],
NSBaselineOffsetAttributeName :
[NSNumber numberWithFloat:baselineOffset],
NSForegroundColorAttributeName : defaultColor,
};
}
case SuggestionAnswer::TextStyle::NONE:
return @{
NSFontAttributeName : [UIFont fontWithDescriptor:defaultFontDescriptor
size:0],
NSForegroundColorAttributeName : defaultColor,
};
}
}
/// Create a formatted string given text and classifications.
- (NSMutableAttributedString*)
attributedStringWithString:(NSString*)text
classifications:(const ACMatchClassifications*)classifications
smallFont:(BOOL)smallFont
color:(UIColor*)defaultColor
dimColor:(UIColor*)dimColor {
if (text == nil)
return nil;
UIFont* fontRef;
fontRef = smallFont
? [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]
: [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
NSMutableAttributedString* styledText =
[[NSMutableAttributedString alloc] initWithString:text];
// Set the base attributes to the default font and color.
NSDictionary* dict = @{
NSFontAttributeName : fontRef,
NSForegroundColorAttributeName : defaultColor,
};
[styledText addAttributes:dict range:NSMakeRange(0, [text length])];
if (classifications != NULL) {
UIFont* boldFontRef;
UIFontDescriptor* fontDescriptor = fontRef.fontDescriptor;
UIFontDescriptor* boldFontDescriptor = [fontDescriptor
fontDescriptorWithSymbolicTraits:UIFontDescriptorTraitBold];
boldFontRef = [UIFont fontWithDescriptor:boldFontDescriptor size:0];
for (ACMatchClassifications::const_iterator i = classifications->begin();
i != classifications->end(); ++i) {
const BOOL isLast = (i + 1) == classifications->end();
const size_t nextOffset = (isLast ? [text length] : (i + 1)->offset);
const NSInteger location = static_cast<NSInteger>(i->offset);
const NSInteger length = static_cast<NSInteger>(nextOffset - i->offset);
// Guard against bad, off-the-end classification ranges due to
// crbug.com/121703 and crbug.com/131370.
if (i->offset + length > [text length] || length <= 0)
break;
const NSRange range = NSMakeRange(location, length);
if (0 != (i->style & ACMatchClassification::MATCH)) {
[styledText addAttribute:NSFontAttributeName
value:boldFontRef
range:range];
}
if (0 != (i->style & ACMatchClassification::DIM)) {
[styledText addAttribute:NSForegroundColorAttributeName
value:dimColor
range:range];
}
}
}
return styledText;
}
@end