// Copyright (c) 2011 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 <Cocoa/Cocoa.h> #include "base/logging.h" // for NOTREACHED() #include "base/mac/mac_util.h" #include "base/sys_string_conversions.h" #include "chrome/browser/tab_contents/confirm_infobar_delegate.h" #include "chrome/browser/tab_contents/link_infobar_delegate.h" #import "chrome/browser/ui/cocoa/animatable_view.h" #include "chrome/browser/ui/cocoa/event_utils.h" #include "chrome/browser/ui/cocoa/infobars/infobar.h" #import "chrome/browser/ui/cocoa/infobars/infobar_container_controller.h" #import "chrome/browser/ui/cocoa/infobars/infobar_controller.h" #include "skia/ext/skia_utils_mac.h" #include "third_party/GTM/AppKit/GTMUILocalizerAndLayoutTweaker.h" #include "webkit/glue/window_open_disposition.h" namespace { // Durations set to match the default SlideAnimation duration. const float kAnimateOpenDuration = 0.12; const float kAnimateCloseDuration = 0.12; } // This simple subclass of |NSTextView| just doesn't show the (text) cursor // (|NSTextView| displays the cursor with full keyboard accessibility enabled). @interface InfoBarTextView : NSTextView - (void)fixupCursor; @end @implementation InfoBarTextView // Never draw the insertion point (otherwise, it shows up without any user // action if full keyboard accessibility is enabled). - (BOOL)shouldDrawInsertionPoint { return NO; } - (NSRange)selectionRangeForProposedRange:(NSRange)proposedSelRange granularity:(NSSelectionGranularity)granularity { // Do not allow selections. return NSMakeRange(0, 0); } // Convince NSTextView to not show an I-Beam cursor when the cursor is over the // text view but not over actual text. // // http://www.mail-archive.com/cocoa-dev@lists.apple.com/msg10791.html // "NSTextView sets the cursor over itself dynamically, based on considerations // including the text under the cursor. It does so in -mouseEntered:, // -mouseMoved:, and -cursorUpdate:, so those would be points to consider // overriding." - (void)mouseMoved:(NSEvent*)e { [super mouseMoved:e]; [self fixupCursor]; } - (void)mouseEntered:(NSEvent*)e { [super mouseEntered:e]; [self fixupCursor]; } - (void)cursorUpdate:(NSEvent*)e { [super cursorUpdate:e]; [self fixupCursor]; } - (void)fixupCursor { if ([[NSCursor currentCursor] isEqual:[NSCursor IBeamCursor]]) [[NSCursor arrowCursor] set]; } @end @interface InfoBarController (PrivateMethods) // Sets |label_| based on |labelPlaceholder_|, sets |labelPlaceholder_| to nil. - (void)initializeLabel; // Asks the container controller to remove the infobar for this delegate. This // call will trigger a notification that starts the infobar animating closed. - (void)removeInfoBar; // Performs final cleanup after an animation is finished or stopped, including // notifying the InfoBarDelegate that the infobar was closed and removing the // infobar from its container, if necessary. - (void)cleanUpAfterAnimation:(BOOL)finished; // Sets the info bar message to the specified |message|, with a hypertext // style link. |link| will be inserted into message at |linkOffset|. - (void)setLabelToMessage:(NSString*)message withLink:(NSString*)link atOffset:(NSUInteger)linkOffset; @end @implementation InfoBarController @synthesize containerController = containerController_; @synthesize delegate = delegate_; - (id)initWithDelegate:(InfoBarDelegate*)delegate { DCHECK(delegate); if ((self = [super initWithNibName:@"InfoBar" bundle:base::mac::MainAppBundle()])) { delegate_ = delegate; } return self; } // All infobars have an icon, so we set up the icon in the base class // awakeFromNib. - (void)awakeFromNib { DCHECK(delegate_); if (delegate_->GetIcon()) { [image_ setImage:gfx::SkBitmapToNSImage(*(delegate_->GetIcon()))]; } else { // No icon, remove it from the view and grow the textfield to include the // space. NSRect imageFrame = [image_ frame]; NSRect labelFrame = [labelPlaceholder_ frame]; labelFrame.size.width += NSMinX(imageFrame) - NSMinX(labelFrame); labelFrame.origin.x = imageFrame.origin.x; [image_ removeFromSuperview]; [labelPlaceholder_ setFrame:labelFrame]; } [self initializeLabel]; [self addAdditionalControls]; } // Called when someone clicks on the embedded link. - (BOOL) textView:(NSTextView*)textView clickedOnLink:(id)link atIndex:(NSUInteger)charIndex { if ([self respondsToSelector:@selector(linkClicked)]) [self performSelector:@selector(linkClicked)]; return YES; } // Called when someone clicks on the ok button. - (void)ok:(id)sender { // Subclasses must override this method if they do not hide the ok button. NOTREACHED(); } // Called when someone clicks on the cancel button. - (void)cancel:(id)sender { // Subclasses must override this method if they do not hide the cancel button. NOTREACHED(); } // Called when someone clicks on the close button. - (void)dismiss:(id)sender { if (delegate_) delegate_->InfoBarDismissed(); [self removeInfoBar]; } - (AnimatableView*)animatableView { return static_cast<AnimatableView*>([self view]); } - (void)open { // Simply reset the frame size to its opened size, forcing a relayout. CGFloat finalHeight = [[self view] frame].size.height; [[self animatableView] setHeight:finalHeight]; } - (void)animateOpen { // Force the frame size to be 0 and then start an animation. NSRect frame = [[self view] frame]; CGFloat finalHeight = frame.size.height; frame.size.height = 0; [[self view] setFrame:frame]; [[self animatableView] animateToNewHeight:finalHeight duration:kAnimateOpenDuration]; } - (void)close { // Stop any running animations. [[self animatableView] stopAnimation]; infoBarClosing_ = YES; [self cleanUpAfterAnimation:YES]; } - (void)animateClosed { // Notify the container of our intentions. [containerController_ willRemoveController:self]; // Start animating closed. We will receive a notification when the animation // is done, at which point we can remove our view from the hierarchy and // notify the delegate that the infobar was closed. [[self animatableView] animateToNewHeight:0 duration:kAnimateCloseDuration]; // The above call may trigger an animationDidStop: notification for any // currently-running animations, so do not set |infoBarClosing_| until after // starting the animation. infoBarClosing_ = YES; } - (void)addAdditionalControls { // Default implementation does nothing. } - (void)infobarWillClose { // Default implementation does nothing. } - (void)setLabelToMessage:(NSString*)message { NSMutableDictionary* attributes = [NSMutableDictionary dictionary]; NSFont* font = [NSFont labelFontOfSize: [NSFont systemFontSizeForControlSize:NSRegularControlSize]]; [attributes setObject:font forKey:NSFontAttributeName]; [attributes setObject:[NSCursor arrowCursor] forKey:NSCursorAttributeName]; scoped_nsobject<NSAttributedString> attributedString( [[NSAttributedString alloc] initWithString:message attributes:attributes]); [[label_.get() textStorage] setAttributedString:attributedString]; } - (void)removeButtons { // Extend the label all the way across. NSRect labelFrame = [label_.get() frame]; labelFrame.size.width = NSMaxX([cancelButton_ frame]) - NSMinX(labelFrame); [okButton_ removeFromSuperview]; [cancelButton_ removeFromSuperview]; [label_.get() setFrame:labelFrame]; } @end @implementation InfoBarController (PrivateMethods) - (void)initializeLabel { // Replace the label placeholder NSTextField with the real label NSTextView. // The former doesn't show links in a nice way, but the latter can't be added // in IB without a containing scroll view, so create the NSTextView // programmatically. label_.reset([[InfoBarTextView alloc] initWithFrame:[labelPlaceholder_ frame]]); [label_.get() setAutoresizingMask:[labelPlaceholder_ autoresizingMask]]; [[labelPlaceholder_ superview] replaceSubview:labelPlaceholder_ with:label_.get()]; labelPlaceholder_ = nil; // Now released. [label_.get() setDelegate:self]; [label_.get() setEditable:NO]; [label_.get() setDrawsBackground:NO]; [label_.get() setHorizontallyResizable:NO]; [label_.get() setVerticallyResizable:NO]; } - (void)removeInfoBar { // TODO(rohitrao): This method can be called even if the infobar has already // been removed and |delegate_| is NULL. Is there a way to rewrite the code // so that inner event loops don't cause us to try and remove the infobar // twice? http://crbug.com/54253 [containerController_ removeDelegate:delegate_]; } - (void)cleanUpAfterAnimation:(BOOL)finished { // Don't need to do any cleanup if the bar was animating open. if (!infoBarClosing_) return; // Notify the delegate that the infobar was closed. The delegate may delete // itself as a result of InfoBarClosed(), so we null out its pointer. if (delegate_) { delegate_->InfoBarClosed(); delegate_ = NULL; } // If the animation ran to completion, then we need to remove ourselves from // the container. If the animation was interrupted, then the container will // take care of removing us. // TODO(rohitrao): UGH! This works for now, but should be cleaner. if (finished) [containerController_ removeController:self]; } - (void)animationDidStop:(NSAnimation*)animation { [self cleanUpAfterAnimation:NO]; } - (void)animationDidEnd:(NSAnimation*)animation { [self cleanUpAfterAnimation:YES]; } // TODO(joth): This method factors out some common functionality between the // various derived infobar classes, however the class hierarchy itself could // use refactoring to reduce this duplication. http://crbug.com/38924 - (void)setLabelToMessage:(NSString*)message withLink:(NSString*)link atOffset:(NSUInteger)linkOffset { if (linkOffset == std::wstring::npos) { // linkOffset == std::wstring::npos means the link should be right-aligned, // which is not supported on Mac (http://crbug.com/47728). NOTIMPLEMENTED(); linkOffset = [message length]; } // Create an attributes dictionary for the entire message. We have // to expicitly set the font the control's font. We also override // the cursor to give us the normal cursor rather than the text // insertion cursor. NSMutableDictionary* linkAttributes = [NSMutableDictionary dictionary]; [linkAttributes setObject:[NSCursor arrowCursor] forKey:NSCursorAttributeName]; NSFont* font = [NSFont labelFontOfSize: [NSFont systemFontSizeForControlSize:NSRegularControlSize]]; [linkAttributes setObject:font forKey:NSFontAttributeName]; // Create the attributed string for the main message text. scoped_nsobject<NSMutableAttributedString> infoText( [[NSMutableAttributedString alloc] initWithString:message]); [infoText.get() addAttributes:linkAttributes range:NSMakeRange(0, [infoText.get() length])]; // Add additional attributes to style the link text appropriately as // well as linkify it. [linkAttributes setObject:[NSColor blueColor] forKey:NSForegroundColorAttributeName]; [linkAttributes setObject:[NSNumber numberWithBool:YES] forKey:NSUnderlineStyleAttributeName]; [linkAttributes setObject:[NSCursor pointingHandCursor] forKey:NSCursorAttributeName]; [linkAttributes setObject:[NSNumber numberWithInt:NSSingleUnderlineStyle] forKey:NSUnderlineStyleAttributeName]; [linkAttributes setObject:[NSString string] // dummy value forKey:NSLinkAttributeName]; // Insert the link text into the string at the appropriate offset. scoped_nsobject<NSAttributedString> attributedString( [[NSAttributedString alloc] initWithString:link attributes:linkAttributes]); [infoText.get() insertAttributedString:attributedString.get() atIndex:linkOffset]; // Update the label view with the new text. [[label_.get() textStorage] setAttributedString:infoText]; } @end ///////////////////////////////////////////////////////////////////////// // LinkInfoBarController implementation @implementation LinkInfoBarController // Link infobars have a text message, of which part is linkified. We // use an NSAttributedString to display styled text, and we set a // NSLink attribute on the hyperlink portion of the message. Infobars // use a custom NSTextField subclass, which allows us to override // textView:clickedOnLink:atIndex: and intercept clicks. // - (void)addAdditionalControls { // No buttons. [self removeButtons]; LinkInfoBarDelegate* delegate = delegate_->AsLinkInfoBarDelegate(); DCHECK(delegate); size_t offset = std::wstring::npos; string16 message = delegate->GetMessageTextWithOffset(&offset); [self setLabelToMessage:base::SysUTF16ToNSString(message) withLink:base::SysUTF16ToNSString(delegate->GetLinkText()) atOffset:offset]; } // Called when someone clicks on the link in the infobar. This method // is called by the InfobarTextField on its delegate (the // LinkInfoBarController). - (void)linkClicked { WindowOpenDisposition disposition = event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); if (delegate_ && delegate_->AsLinkInfoBarDelegate()->LinkClicked(disposition)) [self removeInfoBar]; } @end ///////////////////////////////////////////////////////////////////////// // ConfirmInfoBarController implementation @implementation ConfirmInfoBarController // Called when someone clicks on the "OK" button. - (IBAction)ok:(id)sender { if (delegate_ && delegate_->AsConfirmInfoBarDelegate()->Accept()) [self removeInfoBar]; } // Called when someone clicks on the "Cancel" button. - (IBAction)cancel:(id)sender { if (delegate_ && delegate_->AsConfirmInfoBarDelegate()->Cancel()) [self removeInfoBar]; } // Confirm infobars can have OK and/or cancel buttons, depending on // the return value of GetButtons(). We create each button if // required and position them to the left of the close button. - (void)addAdditionalControls { ConfirmInfoBarDelegate* delegate = delegate_->AsConfirmInfoBarDelegate(); DCHECK(delegate); int visibleButtons = delegate->GetButtons(); NSRect okButtonFrame = [okButton_ frame]; NSRect cancelButtonFrame = [cancelButton_ frame]; DCHECK(NSMaxX(okButtonFrame) < NSMinX(cancelButtonFrame)) << "Cancel button expected to be on the right of the Ok button in nib"; CGFloat rightEdge = NSMaxX(cancelButtonFrame); CGFloat spaceBetweenButtons = NSMinX(cancelButtonFrame) - NSMaxX(okButtonFrame); CGFloat spaceBeforeButtons = NSMinX(okButtonFrame) - NSMaxX([label_.get() frame]); // Update and position the Cancel button if needed. Otherwise, hide it. if (visibleButtons & ConfirmInfoBarDelegate::BUTTON_CANCEL) { [cancelButton_ setTitle:base::SysUTF16ToNSString( delegate->GetButtonLabel(ConfirmInfoBarDelegate::BUTTON_CANCEL))]; [GTMUILocalizerAndLayoutTweaker sizeToFitView:cancelButton_]; cancelButtonFrame = [cancelButton_ frame]; // Position the cancel button to the left of the Close button. cancelButtonFrame.origin.x = rightEdge - cancelButtonFrame.size.width; [cancelButton_ setFrame:cancelButtonFrame]; // Update the rightEdge rightEdge = NSMinX(cancelButtonFrame); } else { [cancelButton_ removeFromSuperview]; } // Update and position the OK button if needed. Otherwise, hide it. if (visibleButtons & ConfirmInfoBarDelegate::BUTTON_OK) { [okButton_ setTitle:base::SysUTF16ToNSString( delegate->GetButtonLabel(ConfirmInfoBarDelegate::BUTTON_OK))]; [GTMUILocalizerAndLayoutTweaker sizeToFitView:okButton_]; okButtonFrame = [okButton_ frame]; // If we had a Cancel button, leave space between the buttons. if (visibleButtons & ConfirmInfoBarDelegate::BUTTON_CANCEL) { rightEdge -= spaceBetweenButtons; } // Position the OK button on our current right edge. okButtonFrame.origin.x = rightEdge - okButtonFrame.size.width; [okButton_ setFrame:okButtonFrame]; // Update the rightEdge rightEdge = NSMinX(okButtonFrame); } else { [okButton_ removeFromSuperview]; } // If we had either button, leave space before the edge of the textfield. if ((visibleButtons & ConfirmInfoBarDelegate::BUTTON_CANCEL) || (visibleButtons & ConfirmInfoBarDelegate::BUTTON_OK)) { rightEdge -= spaceBeforeButtons; } NSRect frame = [label_.get() frame]; DCHECK(rightEdge > NSMinX(frame)) << "Need to make the xib larger to handle buttons with text this long"; frame.size.width = rightEdge - NSMinX(frame); [label_.get() setFrame:frame]; // Set the text and link. NSString* message = base::SysUTF16ToNSString(delegate->GetMessageText()); string16 link = delegate->GetLinkText(); if (link.empty()) { // Simple case: no link, so just set the message directly. [self setLabelToMessage:message]; } else { // Inserting the link unintentionally causes the text to have a slightly // different result to the simple case above: text is truncated on word // boundaries (if needed) rather than elided with ellipses. // Add spacing between the label and the link. message = [message stringByAppendingString:@" "]; [self setLabelToMessage:message withLink:base::SysUTF16ToNSString(link) atOffset:[message length]]; } } // Called when someone clicks on the link in the infobar. This method // is called by the InfobarTextField on its delegate (the // LinkInfoBarController). - (void)linkClicked { WindowOpenDisposition disposition = event_utils::WindowOpenDispositionFromNSEvent([NSApp currentEvent]); if (delegate_ && delegate_->AsConfirmInfoBarDelegate()->LinkClicked(disposition)) [self removeInfoBar]; } @end ////////////////////////////////////////////////////////////////////////// // CreateInfoBar() implementations InfoBar* LinkInfoBarDelegate::CreateInfoBar() { LinkInfoBarController* controller = [[LinkInfoBarController alloc] initWithDelegate:this]; return new InfoBar(controller); } InfoBar* ConfirmInfoBarDelegate::CreateInfoBar() { ConfirmInfoBarController* controller = [[ConfirmInfoBarController alloc] initWithDelegate:this]; return new InfoBar(controller); }