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 "chrome/browser/ui/cocoa/omnibox/omnibox_popup_cell.h" 6 7 #include <algorithm> 8 #include <cmath> 9 10 #include "base/i18n/rtl.h" 11 #include "base/mac/scoped_nsobject.h" 12 #include "base/strings/string_number_conversions.h" 13 #include "base/strings/string_util.h" 14 #include "base/strings/sys_string_conversions.h" 15 #include "base/strings/utf_string_conversions.h" 16 #include "chrome/browser/ui/cocoa/omnibox/omnibox_view_mac.h" 17 #include "chrome/browser/ui/cocoa/omnibox/omnibox_popup_view_mac.h" 18 #include "chrome/browser/ui/omnibox/omnibox_popup_model.h" 19 #include "grit/generated_resources.h" 20 #include "ui/base/l10n/l10n_util.h" 21 #include "ui/gfx/font.h" 22 23 namespace { 24 25 // How far to offset image column from the left. 26 const CGFloat kImageXOffset = 5.0; 27 28 // How far to offset the text column from the left. 29 const CGFloat kTextStartOffset = 28.0; 30 31 // Rounding radius of selection and hover background on popup items. 32 const CGFloat kCellRoundingRadius = 2.0; 33 34 // Flips the given |rect| in context of the given |frame|. 35 NSRect FlipIfRTL(NSRect rect, NSRect frame) { 36 DCHECK_LE(NSMinX(frame), NSMinX(rect)); 37 DCHECK_GE(NSMaxX(frame), NSMaxX(rect)); 38 if (base::i18n::IsRTL()) { 39 NSRect result = rect; 40 result.origin.x = NSMinX(frame) + (NSMaxX(frame) - NSMaxX(rect)); 41 return result; 42 } 43 return rect; 44 } 45 46 // Shifts the left edge of the given |rect| by |dX| 47 NSRect ShiftRect(NSRect rect, CGFloat dX) { 48 DCHECK_LE(dX, NSWidth(rect)); 49 NSRect result = rect; 50 result.origin.x += dX; 51 result.size.width -= dX; 52 return result; 53 } 54 55 NSColor* SelectedBackgroundColor() { 56 return [NSColor selectedControlColor]; 57 } 58 NSColor* HoveredBackgroundColor() { 59 return [NSColor controlHighlightColor]; 60 } 61 62 NSColor* ContentTextColor() { 63 return [NSColor blackColor]; 64 } 65 NSColor* DimTextColor() { 66 return [NSColor darkGrayColor]; 67 } 68 NSColor* URLTextColor() { 69 return [NSColor colorWithCalibratedRed:0.0 green:0.55 blue:0.0 alpha:1.0]; 70 } 71 72 NSFont* FieldFont() { 73 return OmniboxViewMac::GetFieldFont(gfx::Font::NORMAL); 74 } 75 NSFont* BoldFieldFont() { 76 return OmniboxViewMac::GetFieldFont(gfx::Font::BOLD); 77 } 78 79 CGFloat GetContentAreaWidth(NSRect cellFrame) { 80 return NSWidth(cellFrame) - kTextStartOffset; 81 } 82 83 NSMutableAttributedString* CreateAttributedString( 84 const base::string16& text, 85 NSColor* text_color, 86 NSTextAlignment textAlignment) { 87 // Start out with a string using the default style info. 88 NSString* s = base::SysUTF16ToNSString(text); 89 NSDictionary* attributes = @{ 90 NSFontAttributeName : FieldFont(), 91 NSForegroundColorAttributeName : text_color 92 }; 93 NSMutableAttributedString* as = 94 [[[NSMutableAttributedString alloc] initWithString:s 95 attributes:attributes] 96 autorelease]; 97 98 NSMutableParagraphStyle* style = 99 [[[NSMutableParagraphStyle alloc] init] autorelease]; 100 [style setLineBreakMode:NSLineBreakByTruncatingTail]; 101 [style setTighteningFactorForTruncation:0.0]; 102 [style setAlignment:textAlignment]; 103 [as addAttribute:NSParagraphStyleAttributeName 104 value:style 105 range:NSMakeRange(0, [as length])]; 106 107 return as; 108 } 109 110 NSMutableAttributedString* CreateAttributedString( 111 const base::string16& text, 112 NSColor* text_color) { 113 return CreateAttributedString(text, text_color, NSNaturalTextAlignment); 114 } 115 116 NSAttributedString* CreateClassifiedAttributedString( 117 const base::string16& text, 118 NSColor* text_color, 119 const ACMatchClassifications& classifications) { 120 NSMutableAttributedString* as = CreateAttributedString(text, text_color); 121 NSUInteger match_length = [as length]; 122 123 // Mark up the runs which differ from the default. 124 for (ACMatchClassifications::const_iterator i = classifications.begin(); 125 i != classifications.end(); ++i) { 126 const bool is_last = ((i + 1) == classifications.end()); 127 const NSUInteger next_offset = 128 (is_last ? match_length : static_cast<NSUInteger>((i + 1)->offset)); 129 const NSUInteger location = static_cast<NSUInteger>(i->offset); 130 const NSUInteger length = next_offset - static_cast<NSUInteger>(i->offset); 131 // Guard against bad, off-the-end classification ranges. 132 if (location >= match_length || length <= 0) 133 break; 134 const NSRange range = 135 NSMakeRange(location, std::min(length, match_length - location)); 136 137 if (0 != (i->style & ACMatchClassification::MATCH)) { 138 [as addAttribute:NSFontAttributeName value:BoldFieldFont() range:range]; 139 } 140 141 if (0 != (i->style & ACMatchClassification::URL)) { 142 [as addAttribute:NSForegroundColorAttributeName 143 value:URLTextColor() 144 range:range]; 145 } else if (0 != (i->style & ACMatchClassification::DIM)) { 146 [as addAttribute:NSForegroundColorAttributeName 147 value:DimTextColor() 148 range:range]; 149 } 150 } 151 152 return as; 153 } 154 155 } // namespace 156 157 @implementation OmniboxPopupCell 158 159 - (id)init { 160 self = [super init]; 161 if (self) { 162 [self setImagePosition:NSImageLeft]; 163 [self setBordered:NO]; 164 [self setButtonType:NSRadioButton]; 165 166 // Without this highlighting messes up white areas of images. 167 [self setHighlightsBy:NSNoCellMask]; 168 169 const base::string16& raw_separator = 170 l10n_util::GetStringUTF16(IDS_AUTOCOMPLETE_MATCH_DESCRIPTION_SEPARATOR); 171 separator_.reset( 172 [CreateAttributedString(raw_separator, DimTextColor()) retain]); 173 } 174 return self; 175 } 176 177 - (void)setMatch:(const AutocompleteMatch&)match { 178 match_ = match; 179 NSAttributedString *contents = CreateClassifiedAttributedString( 180 match_.contents, ContentTextColor(), match_.contents_class); 181 [self setAttributedTitle:contents]; 182 183 if (match_.description.empty()) { 184 description_.reset(); 185 } else { 186 description_.reset([CreateClassifiedAttributedString( 187 match_.description, DimTextColor(), match_.description_class) retain]); 188 } 189 } 190 191 - (void)setMaxMatchContentsWidth:(CGFloat)maxMatchContentsWidth { 192 maxMatchContentsWidth_ = maxMatchContentsWidth; 193 } 194 195 - (void)setContentsOffset:(CGFloat)contentsOffset { 196 contentsOffset_ = contentsOffset; 197 } 198 199 // The default NSButtonCell drawing leaves the image flush left and 200 // the title next to the image. This spaces things out to line up 201 // with the star button and autocomplete field. 202 - (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView *)controlView { 203 if ([self state] == NSOnState || [self isHighlighted]) { 204 if ([self state] == NSOnState) 205 [SelectedBackgroundColor() set]; 206 else 207 [HoveredBackgroundColor() set]; 208 NSBezierPath* path = 209 [NSBezierPath bezierPathWithRoundedRect:cellFrame 210 xRadius:kCellRoundingRadius 211 yRadius:kCellRoundingRadius]; 212 [path fill]; 213 } 214 215 // Put the image centered vertically but in a fixed column. 216 NSImage* image = [self image]; 217 if (image) { 218 NSRect imageRect = cellFrame; 219 imageRect.size = [image size]; 220 imageRect.origin.y += 221 std::floor((NSHeight(cellFrame) - NSHeight(imageRect)) / 2.0); 222 imageRect.origin.x += kImageXOffset; 223 [image drawInRect:FlipIfRTL(imageRect, cellFrame) 224 fromRect:NSZeroRect // Entire image 225 operation:NSCompositeSourceOver 226 fraction:1.0 227 respectFlipped:YES 228 hints:nil]; 229 } 230 231 [self drawMatchWithFrame:cellFrame inView:controlView]; 232 } 233 234 - (void)drawMatchWithFrame:(NSRect)cellFrame inView:(NSView*)controlView { 235 NSAttributedString* contents = [self attributedTitle]; 236 237 CGFloat remainingWidth = GetContentAreaWidth(cellFrame); 238 CGFloat contentsWidth = [self getMatchContentsWidth]; 239 CGFloat separatorWidth = [separator_ size].width; 240 CGFloat descriptionWidth = description_.get() ? [description_ size].width : 0; 241 int contentsMaxWidth, descriptionMaxWidth; 242 OmniboxPopupModel::ComputeMatchMaxWidths( 243 ceilf(contentsWidth), 244 ceilf(separatorWidth), 245 ceilf(descriptionWidth), 246 ceilf(remainingWidth), 247 !AutocompleteMatch::IsSearchType(match_.type), 248 &contentsMaxWidth, 249 &descriptionMaxWidth); 250 251 CGFloat offset = kTextStartOffset; 252 if (match_.type == AutocompleteMatchType::SEARCH_SUGGEST_INFINITE) { 253 // Infinite suggestions are rendered with a prefix (usually ellipsis), which 254 // appear vertically stacked. 255 offset += [self drawMatchPrefixWithFrame:cellFrame 256 inView:controlView 257 withContentsMaxWidth:&contentsMaxWidth]; 258 } 259 offset += [self drawMatchPart:contents 260 withFrame:cellFrame 261 atOffset:offset 262 withMaxWidth:contentsMaxWidth 263 inView:controlView]; 264 265 if (descriptionMaxWidth != 0) { 266 offset += [self drawMatchPart:separator_ 267 withFrame:cellFrame 268 atOffset:offset 269 withMaxWidth:separatorWidth 270 inView:controlView]; 271 offset += [self drawMatchPart:description_ 272 withFrame:cellFrame 273 atOffset:offset 274 withMaxWidth:descriptionMaxWidth 275 inView:controlView]; 276 } 277 } 278 279 - (CGFloat)drawMatchPrefixWithFrame:(NSRect)cellFrame 280 inView:(NSView*)controlView 281 withContentsMaxWidth:(int*)contentsMaxWidth { 282 CGFloat offset = 0.0f; 283 CGFloat remainingWidth = GetContentAreaWidth(cellFrame); 284 bool isRTL = base::i18n::IsRTL(); 285 bool isContentsRTL = (base::i18n::RIGHT_TO_LEFT == 286 base::i18n::GetFirstStrongCharacterDirection(match_.contents)); 287 // Prefix may not have any characters with strong directionality, and may take 288 // the UI directionality. But prefix needs to appear in continuation of the 289 // contents so we force the directionality. 290 NSTextAlignment textAlignment = isContentsRTL ? 291 NSRightTextAlignment : NSLeftTextAlignment; 292 prefix_.reset([CreateAttributedString(base::UTF8ToUTF16( 293 match_.GetAdditionalInfo(kACMatchPropertyContentsPrefix)), 294 ContentTextColor(), textAlignment) retain]); 295 CGFloat prefixWidth = [prefix_ size].width; 296 297 CGFloat prefixOffset = 0.0f; 298 if (isRTL != isContentsRTL) { 299 // The contents is rendered between the contents offset extending towards 300 // the start edge, while prefix is rendered in opposite direction. Ideally 301 // the prefix should be rendered at |contentsOffset_|. If that is not 302 // sufficient to render the widest suggestion, we increase it to 303 // |maxMatchContentsWidth_|. If |remainingWidth| is not sufficient to 304 // accomodate that, we reduce the offset so that the prefix gets rendered. 305 prefixOffset = std::min( 306 remainingWidth - prefixWidth, std::max(contentsOffset_, 307 maxMatchContentsWidth_)); 308 offset = std::max<CGFloat>(0.0, prefixOffset - *contentsMaxWidth); 309 } else { // The direction of contents is same as UI direction. 310 // Ideally the offset should be |contentsOffset_|. If the max total width 311 // (|prefixWidth| + |maxMatchContentsWidth_|) from offset will exceed the 312 // |remainingWidth|, then we shift the offset to the left , so that all 313 // postfix suggestions are visible. 314 // We have to render the prefix, so offset has to be at least |prefixWidth|. 315 offset = std::max(prefixWidth, 316 std::min(remainingWidth - maxMatchContentsWidth_, contentsOffset_)); 317 prefixOffset = offset - prefixWidth; 318 } 319 *contentsMaxWidth = std::min((int)ceilf(remainingWidth - prefixWidth), 320 *contentsMaxWidth); 321 [self drawMatchPart:prefix_ 322 withFrame:cellFrame 323 atOffset:prefixOffset + kTextStartOffset 324 withMaxWidth:prefixWidth 325 inView:controlView]; 326 return offset; 327 } 328 329 - (CGFloat)drawMatchPart:(NSAttributedString*)as 330 withFrame:(NSRect)cellFrame 331 atOffset:(CGFloat)offset 332 withMaxWidth:(int)maxWidth 333 inView:(NSView*)controlView { 334 if (offset > NSWidth(cellFrame)) 335 return 0.0f; 336 NSRect renderRect = ShiftRect(cellFrame, offset); 337 renderRect.size.width = 338 std::min(NSWidth(renderRect), static_cast<CGFloat>(maxWidth)); 339 if (renderRect.size.width != 0) { 340 [self drawTitle:as 341 withFrame:FlipIfRTL(renderRect, cellFrame) 342 inView:controlView]; 343 } 344 return NSWidth(renderRect); 345 } 346 347 - (CGFloat)getMatchContentsWidth { 348 NSAttributedString* contents = [self attributedTitle]; 349 return contents ? [contents size].width : 0; 350 } 351 352 353 + (CGFloat)computeContentsOffset:(const AutocompleteMatch&)match { 354 const base::string16& inputText = base::UTF8ToUTF16( 355 match.GetAdditionalInfo(kACMatchPropertyInputText)); 356 int contentsStartIndex = 0; 357 base::StringToInt( 358 match.GetAdditionalInfo(kACMatchPropertyContentsStartIndex), 359 &contentsStartIndex); 360 // Ignore invalid state. 361 if (!StartsWith(match.fill_into_edit, inputText, true) 362 || !EndsWith(match.fill_into_edit, match.contents, true) 363 || ((size_t)contentsStartIndex >= inputText.length())) { 364 return 0; 365 } 366 bool isRTL = base::i18n::IsRTL(); 367 bool isContentsRTL = (base::i18n::RIGHT_TO_LEFT == 368 base::i18n::GetFirstStrongCharacterDirection(match.contents)); 369 370 // Color does not matter. 371 NSAttributedString* as = CreateAttributedString(inputText, DimTextColor()); 372 base::scoped_nsobject<NSTextStorage> textStorage([[NSTextStorage alloc] 373 initWithAttributedString:as]); 374 base::scoped_nsobject<NSLayoutManager> layoutManager( 375 [[NSLayoutManager alloc] init]); 376 base::scoped_nsobject<NSTextContainer> textContainer( 377 [[NSTextContainer alloc] init]); 378 [layoutManager addTextContainer:textContainer]; 379 [textStorage addLayoutManager:layoutManager]; 380 381 NSUInteger charIndex = static_cast<NSUInteger>(contentsStartIndex); 382 NSUInteger glyphIndex = 383 [layoutManager glyphIndexForCharacterAtIndex:charIndex]; 384 385 // This offset is computed from the left edge of the glyph always from the 386 // left edge of the string, irrespective of the directionality of UI or text. 387 CGFloat glyphOffset = [layoutManager locationForGlyphAtIndex:glyphIndex].x; 388 389 CGFloat inputWidth = [as size].width; 390 391 // The offset obtained above may need to be corrected because the left-most 392 // glyph may not have 0 offset. So we find the offset of left-most glyph, and 393 // subtract it from the offset of the glyph we obtained above. 394 CGFloat minOffset = glyphOffset; 395 396 // If content is RTL, we are interested in the right-edge of the glyph. 397 // Unfortunately the bounding rect computation methods from NSLayoutManager or 398 // NSFont don't work correctly with bidirectional text. So we compute the 399 // glyph width by finding the closest glyph offset to the right of the glyph 400 // we are looking for. 401 CGFloat glyphWidth = inputWidth; 402 403 for (NSUInteger i = 0; i < [as length]; i++) { 404 if (i == charIndex) continue; 405 glyphIndex = [layoutManager glyphIndexForCharacterAtIndex:i]; 406 CGFloat offset = [layoutManager locationForGlyphAtIndex:glyphIndex].x; 407 minOffset = std::min(minOffset, offset); 408 if (offset > glyphOffset) 409 glyphWidth = std::min(glyphWidth, offset - glyphOffset); 410 } 411 glyphOffset -= minOffset; 412 if (glyphWidth == 0) 413 glyphWidth = inputWidth - glyphOffset; 414 if (isContentsRTL) 415 glyphOffset += glyphWidth; 416 return isRTL ? (inputWidth - glyphOffset) : glyphOffset; 417 } 418 419 @end 420