// Copyright (c) 2009 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. #include "chrome/browser/ui/cocoa/status_bubble_mac.h" #include <limits> #include "base/compiler_specific.h" #include "base/message_loop.h" #include "base/string_util.h" #include "base/sys_string_conversions.h" #include "base/utf_string_conversions.h" #import "chrome/browser/ui/cocoa/bubble_view.h" #include "net/base/net_util.h" #import "third_party/GTM/AppKit/GTMNSAnimation+Duration.h" #import "third_party/GTM/AppKit/GTMNSBezierPath+RoundRect.h" #import "third_party/GTM/AppKit/GTMNSColor+Luminance.h" #include "ui/base/text/text_elider.h" #include "ui/gfx/point.h" namespace { const int kWindowHeight = 18; // The width of the bubble in relation to the width of the parent window. const CGFloat kWindowWidthPercent = 1.0 / 3.0; // How close the mouse can get to the infobubble before it starts sliding // off-screen. const int kMousePadding = 20; const int kTextPadding = 3; // The animation key used for fade-in and fade-out transitions. NSString* const kFadeAnimationKey = @"alphaValue"; // The status bubble's maximum opacity, when fully faded in. const CGFloat kBubbleOpacity = 1.0; // Delay before showing or hiding the bubble after a SetStatus or SetURL call. const int64 kShowDelayMilliseconds = 80; const int64 kHideDelayMilliseconds = 250; // How long each fade should last. const NSTimeInterval kShowFadeInDurationSeconds = 0.120; const NSTimeInterval kHideFadeOutDurationSeconds = 0.200; // The minimum representable time interval. This can be used as the value // passed to +[NSAnimationContext setDuration:] to stop an in-progress // animation as quickly as possible. const NSTimeInterval kMinimumTimeInterval = std::numeric_limits<NSTimeInterval>::min(); // How quickly the status bubble should expand, in seconds. const CGFloat kExpansionDuration = 0.125; } // namespace @interface StatusBubbleAnimationDelegate : NSObject { @private StatusBubbleMac* statusBubble_; // weak; owns us indirectly } - (id)initWithStatusBubble:(StatusBubbleMac*)statusBubble; // Invalidates this object so that no further calls will be made to // statusBubble_. This should be called when statusBubble_ is released, to // prevent attempts to call into the released object. - (void)invalidate; // CAAnimation delegate method - (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)finished; @end @implementation StatusBubbleAnimationDelegate - (id)initWithStatusBubble:(StatusBubbleMac*)statusBubble { if ((self = [super init])) { statusBubble_ = statusBubble; } return self; } - (void)invalidate { statusBubble_ = NULL; } - (void)animationDidStop:(CAAnimation*)animation finished:(BOOL)finished { if (statusBubble_) statusBubble_->AnimationDidStop(animation, finished ? true : false); } @end StatusBubbleMac::StatusBubbleMac(NSWindow* parent, id delegate) : ALLOW_THIS_IN_INITIALIZER_LIST(timer_factory_(this)), ALLOW_THIS_IN_INITIALIZER_LIST(expand_timer_factory_(this)), parent_(parent), delegate_(delegate), window_(nil), status_text_(nil), url_text_(nil), state_(kBubbleHidden), immediate_(false), is_expanded_(false) { Create(); Attach(); } StatusBubbleMac::~StatusBubbleMac() { DCHECK(window_); Hide(); [[[window_ animationForKey:kFadeAnimationKey] delegate] invalidate]; Detach(); [window_ release]; window_ = nil; } void StatusBubbleMac::SetStatus(const string16& status) { SetText(status, false); } void StatusBubbleMac::SetURL(const GURL& url, const string16& languages) { url_ = url; languages_ = languages; NSRect frame = [window_ frame]; // Reset frame size when bubble is hidden. if (state_ == kBubbleHidden) { is_expanded_ = false; frame.size.width = NSWidth(CalculateWindowFrame(/*expand=*/false)); [window_ setFrame:frame display:NO]; } int text_width = static_cast<int>(NSWidth(frame) - kBubbleViewTextPositionX - kTextPadding); // Scale from view to window coordinates before eliding URL string. NSSize scaled_width = NSMakeSize(text_width, 0); scaled_width = [[parent_ contentView] convertSize:scaled_width fromView:nil]; text_width = static_cast<int>(scaled_width.width); NSFont* font = [[window_ contentView] font]; gfx::Font font_chr(base::SysNSStringToUTF16([font fontName]), [font pointSize]); string16 original_url_text = net::FormatUrl(url, UTF16ToUTF8(languages)); string16 status = ui::ElideUrl(url, font_chr, text_width, UTF16ToUTF8(languages)); SetText(status, true); // In testing, don't use animation. When ExpandBubble is tested, it is // called explicitly. if (immediate_) return; else CancelExpandTimer(); // If the bubble has been expanded, the user has already hovered over a link // to trigger the expanded state. Don't wait to change the bubble in this // case -- immediately expand or contract to fit the URL. if (is_expanded_ && !url.is_empty()) { ExpandBubble(); } else if (original_url_text.length() > status.length()) { MessageLoop::current()->PostDelayedTask(FROM_HERE, expand_timer_factory_.NewRunnableMethod( &StatusBubbleMac::ExpandBubble), kExpandHoverDelay); } } void StatusBubbleMac::SetText(const string16& text, bool is_url) { // The status bubble allows the status and URL strings to be set // independently. Whichever was set non-empty most recently will be the // value displayed. When both are empty, the status bubble hides. NSString* text_ns = base::SysUTF16ToNSString(text); NSString** main; NSString** backup; if (is_url) { main = &url_text_; backup = &status_text_; } else { main = &status_text_; backup = &url_text_; } // Don't return from this function early. It's important to make sure that // all calls to StartShowing and StartHiding are made, so that all delays // are observed properly. Specifically, if the state is currently // kBubbleShowingTimer, the timer will need to be restarted even if // [text_ns isEqualToString:*main] is true. [*main autorelease]; *main = [text_ns retain]; bool show = true; if ([*main length] > 0) [[window_ contentView] setContent:*main]; else if ([*backup length] > 0) [[window_ contentView] setContent:*backup]; else show = false; if (show) StartShowing(); else StartHiding(); } void StatusBubbleMac::Hide() { CancelTimer(); CancelExpandTimer(); is_expanded_ = false; bool fade_out = false; if (state_ == kBubbleHidingFadeOut || state_ == kBubbleShowingFadeIn) { SetState(kBubbleHidingFadeOut); if (!immediate_) { // An animation is in progress. Cancel it by starting a new animation. // Use kMinimumTimeInterval to set the opacity as rapidly as possible. fade_out = true; [NSAnimationContext beginGrouping]; [[NSAnimationContext currentContext] setDuration:kMinimumTimeInterval]; [[window_ animator] setAlphaValue:0.0]; [NSAnimationContext endGrouping]; } } if (!fade_out) { // No animation is in progress, so the opacity can be set directly. [window_ setAlphaValue:0.0]; SetState(kBubbleHidden); } // Stop any width animation and reset the bubble size. if (!immediate_) { [NSAnimationContext beginGrouping]; [[NSAnimationContext currentContext] setDuration:kMinimumTimeInterval]; [[window_ animator] setFrame:CalculateWindowFrame(/*expand=*/false) display:NO]; [NSAnimationContext endGrouping]; } else { [window_ setFrame:CalculateWindowFrame(/*expand=*/false) display:NO]; } [status_text_ release]; status_text_ = nil; [url_text_ release]; url_text_ = nil; } void StatusBubbleMac::MouseMoved( const gfx::Point& location, bool left_content) { if (left_content) return; if (!window_) return; // TODO(thakis): Use 'location' here instead of NSEvent. NSPoint cursor_location = [NSEvent mouseLocation]; --cursor_location.y; // docs say the y coord starts at 1 not 0; don't ask why // Bubble's base frame in |parent_| coordinates. NSRect baseFrame; if ([delegate_ respondsToSelector:@selector(statusBubbleBaseFrame)]) baseFrame = [delegate_ statusBubbleBaseFrame]; else baseFrame = [[parent_ contentView] frame]; // Get the normal position of the frame. NSRect window_frame = [window_ frame]; window_frame.origin = [parent_ convertBaseToScreen:baseFrame.origin]; // Get the cursor position relative to the popup. cursor_location.x -= NSMaxX(window_frame); cursor_location.y -= NSMaxY(window_frame); // If the mouse is in a position where we think it would move the // status bubble, figure out where and how the bubble should be moved. if (cursor_location.y < kMousePadding && cursor_location.x < kMousePadding) { int offset = kMousePadding - cursor_location.y; // Make the movement non-linear. offset = offset * offset / kMousePadding; // When the mouse is entering from the right, we want the offset to be // scaled by how horizontally far away the cursor is from the bubble. if (cursor_location.x > 0) { offset = offset * ((kMousePadding - cursor_location.x) / kMousePadding); } bool isOnScreen = true; NSScreen* screen = [window_ screen]; if (screen && NSMinY([screen visibleFrame]) > NSMinY(window_frame) - offset) { isOnScreen = false; } // If something is shown below tab contents (devtools, download shelf etc.), // adjust the position to sit on top of it. bool isAnyShelfVisible = NSMinY(baseFrame) > 0; if (isOnScreen && !isAnyShelfVisible) { // Cap the offset and change the visual presentation of the bubble // depending on where it ends up (so that rounded corners square off // and mate to the edges of the tab content). if (offset >= NSHeight(window_frame)) { offset = NSHeight(window_frame); [[window_ contentView] setCornerFlags: kRoundedBottomLeftCorner | kRoundedBottomRightCorner]; } else if (offset > 0) { [[window_ contentView] setCornerFlags: kRoundedTopRightCorner | kRoundedBottomLeftCorner | kRoundedBottomRightCorner]; } else { [[window_ contentView] setCornerFlags:kRoundedTopRightCorner]; } window_frame.origin.y -= offset; } else { // Cannot move the bubble down without obscuring other content. // Move it to the right instead. [[window_ contentView] setCornerFlags:kRoundedTopLeftCorner]; // Subtract border width + bubble width. window_frame.origin.x += NSWidth(baseFrame) - NSWidth(window_frame); } } else { [[window_ contentView] setCornerFlags:kRoundedTopRightCorner]; } [window_ setFrame:window_frame display:YES]; } void StatusBubbleMac::UpdateDownloadShelfVisibility(bool visible) { } void StatusBubbleMac::Create() { DCHECK(!window_); window_ = [[NSWindow alloc] initWithContentRect:NSZeroRect styleMask:NSBorderlessWindowMask backing:NSBackingStoreBuffered defer:YES]; [window_ setMovableByWindowBackground:NO]; [window_ setBackgroundColor:[NSColor clearColor]]; [window_ setLevel:NSNormalWindowLevel]; [window_ setOpaque:NO]; [window_ setHasShadow:NO]; // We do not need to worry about the bubble outliving |parent_| because our // teardown sequence in BWC guarantees that |parent_| outlives the status // bubble and that the StatusBubble is torn down completely prior to the // window going away. scoped_nsobject<BubbleView> view( [[BubbleView alloc] initWithFrame:NSZeroRect themeProvider:parent_]); [window_ setContentView:view]; [window_ setAlphaValue:0.0]; // Set a delegate for the fade-in and fade-out transitions to be notified // when fades are complete. The ownership model is for window_ to own // animation_dictionary, which owns animation, which owns // animation_delegate. CAAnimation* animation = [[window_ animationForKey:kFadeAnimationKey] copy]; [animation autorelease]; StatusBubbleAnimationDelegate* animation_delegate = [[StatusBubbleAnimationDelegate alloc] initWithStatusBubble:this]; [animation_delegate autorelease]; [animation setDelegate:animation_delegate]; NSMutableDictionary* animation_dictionary = [NSMutableDictionary dictionaryWithDictionary:[window_ animations]]; [animation_dictionary setObject:animation forKey:kFadeAnimationKey]; [window_ setAnimations:animation_dictionary]; [view setCornerFlags:kRoundedTopRightCorner]; MouseMoved(gfx::Point(), false); } void StatusBubbleMac::Attach() { DCHECK(!is_attached()); [window_ orderFront:nil]; [parent_ addChildWindow:window_ ordered:NSWindowAbove]; [[window_ contentView] setThemeProvider:parent_]; } void StatusBubbleMac::Detach() { DCHECK(is_attached()); // Magic setFrame: See crbug.com/58506, and codereview.chromium.org/3564021 [window_ setFrame:CalculateWindowFrame(/*expand=*/false) display:NO]; [parent_ removeChildWindow:window_]; // See crbug.com/28107 ... [window_ orderOut:nil]; // ... and crbug.com/29054. [[window_ contentView] setThemeProvider:nil]; } void StatusBubbleMac::AnimationDidStop(CAAnimation* animation, bool finished) { DCHECK([NSThread isMainThread]); DCHECK(state_ == kBubbleShowingFadeIn || state_ == kBubbleHidingFadeOut); DCHECK(is_attached()); if (finished) { // Because of the mechanism used to interrupt animations, this is never // actually called with finished set to false. If animations ever become // directly interruptible, the check will ensure that state_ remains // properly synchronized. if (state_ == kBubbleShowingFadeIn) { DCHECK_EQ([[window_ animator] alphaValue], kBubbleOpacity); SetState(kBubbleShown); } else { DCHECK_EQ([[window_ animator] alphaValue], 0.0); SetState(kBubbleHidden); } } } void StatusBubbleMac::SetState(StatusBubbleState state) { if (state == state_) return; if (state == kBubbleHidden) [window_ setFrame:NSZeroRect display:YES]; else UpdateSizeAndPosition(); if ([delegate_ respondsToSelector:@selector(statusBubbleWillEnterState:)]) [delegate_ statusBubbleWillEnterState:state]; state_ = state; } void StatusBubbleMac::Fade(bool show) { DCHECK([NSThread isMainThread]); StatusBubbleState fade_state = kBubbleShowingFadeIn; StatusBubbleState target_state = kBubbleShown; NSTimeInterval full_duration = kShowFadeInDurationSeconds; CGFloat opacity = kBubbleOpacity; if (!show) { fade_state = kBubbleHidingFadeOut; target_state = kBubbleHidden; full_duration = kHideFadeOutDurationSeconds; opacity = 0.0; } DCHECK(state_ == fade_state || state_ == target_state); if (state_ == target_state) return; if (immediate_) { [window_ setAlphaValue:opacity]; SetState(target_state); return; } // If an incomplete transition has left the opacity somewhere between 0 and // kBubbleOpacity, the fade rate is kept constant by shortening the duration. NSTimeInterval duration = full_duration * fabs(opacity - [[window_ animator] alphaValue]) / kBubbleOpacity; // 0.0 will not cancel an in-progress animation. if (duration == 0.0) duration = kMinimumTimeInterval; // This will cancel an in-progress transition and replace it with this fade. [NSAnimationContext beginGrouping]; // Don't use the GTM additon for the "Steve" slowdown because this can happen // async from user actions and the effects could be a surprise. [[NSAnimationContext currentContext] setDuration:duration]; [[window_ animator] setAlphaValue:opacity]; [NSAnimationContext endGrouping]; } void StatusBubbleMac::StartTimer(int64 delay_ms) { DCHECK([NSThread isMainThread]); DCHECK(state_ == kBubbleShowingTimer || state_ == kBubbleHidingTimer); if (immediate_) { TimerFired(); return; } // There can only be one running timer. CancelTimer(); MessageLoop::current()->PostDelayedTask( FROM_HERE, timer_factory_.NewRunnableMethod(&StatusBubbleMac::TimerFired), delay_ms); } void StatusBubbleMac::CancelTimer() { DCHECK([NSThread isMainThread]); if (!timer_factory_.empty()) timer_factory_.RevokeAll(); } void StatusBubbleMac::TimerFired() { DCHECK(state_ == kBubbleShowingTimer || state_ == kBubbleHidingTimer); DCHECK([NSThread isMainThread]); if (state_ == kBubbleShowingTimer) { SetState(kBubbleShowingFadeIn); Fade(true); } else { SetState(kBubbleHidingFadeOut); Fade(false); } } void StatusBubbleMac::StartShowing() { if (state_ == kBubbleHidden) { // Arrange to begin fading in after a delay. SetState(kBubbleShowingTimer); StartTimer(kShowDelayMilliseconds); } else if (state_ == kBubbleHidingFadeOut) { // Cancel the fade-out in progress and replace it with a fade in. SetState(kBubbleShowingFadeIn); Fade(true); } else if (state_ == kBubbleHidingTimer) { // The bubble was already shown but was waiting to begin fading out. It's // given a stay of execution. SetState(kBubbleShown); CancelTimer(); } else if (state_ == kBubbleShowingTimer) { // The timer was already running but nothing was showing yet. Reaching // this point means that there is a new request to show something. Start // over again by resetting the timer, effectively invalidating the earlier // request. StartTimer(kShowDelayMilliseconds); } // If the state is kBubbleShown or kBubbleShowingFadeIn, leave everything // alone. } void StatusBubbleMac::StartHiding() { if (state_ == kBubbleShown) { // Arrange to begin fading out after a delay. SetState(kBubbleHidingTimer); StartTimer(kHideDelayMilliseconds); } else if (state_ == kBubbleShowingFadeIn) { // Cancel the fade-in in progress and replace it with a fade out. SetState(kBubbleHidingFadeOut); Fade(false); } else if (state_ == kBubbleShowingTimer) { // The bubble was already hidden but was waiting to begin fading in. Too // bad, it won't get the opportunity now. SetState(kBubbleHidden); CancelTimer(); } // If the state is kBubbleHidden, kBubbleHidingFadeOut, or // kBubbleHidingTimer, leave everything alone. The timer is not reset as // with kBubbleShowingTimer in StartShowing() because a subsequent request // to hide something while one is already in flight does not invalidate the // earlier request. } void StatusBubbleMac::CancelExpandTimer() { DCHECK([NSThread isMainThread]); expand_timer_factory_.RevokeAll(); } void StatusBubbleMac::ExpandBubble() { // Calculate the width available for expanded and standard bubbles. NSRect window_frame = CalculateWindowFrame(/*expand=*/true); CGFloat max_bubble_width = NSWidth(window_frame); CGFloat standard_bubble_width = NSWidth(CalculateWindowFrame(/*expand=*/false)); // Generate the URL string that fits in the expanded bubble. NSFont* font = [[window_ contentView] font]; gfx::Font font_chr(base::SysNSStringToUTF16([font fontName]), [font pointSize]); string16 expanded_url = ui::ElideUrl(url_, font_chr, max_bubble_width, UTF16ToUTF8(languages_)); // Scale width from gfx::Font in view coordinates to window coordinates. int required_width_for_string = font_chr.GetStringWidth(expanded_url) + kTextPadding * 2 + kBubbleViewTextPositionX; NSSize scaled_width = NSMakeSize(required_width_for_string, 0); scaled_width = [[parent_ contentView] convertSize:scaled_width toView:nil]; required_width_for_string = scaled_width.width; // The expanded width must be at least as wide as the standard width, but no // wider than the maximum width for its parent frame. int expanded_bubble_width = std::max(standard_bubble_width, std::min(max_bubble_width, static_cast<CGFloat>(required_width_for_string))); SetText(expanded_url, true); is_expanded_ = true; window_frame.size.width = expanded_bubble_width; // In testing, don't do any animation. if (immediate_) { [window_ setFrame:window_frame display:YES]; return; } NSRect actual_window_frame = [window_ frame]; // Adjust status bubble origin if bubble was moved to the right. // TODO(alekseys): fix for RTL. if (NSMinX(actual_window_frame) > NSMinX(window_frame)) { actual_window_frame.origin.x = NSMaxX(actual_window_frame) - NSWidth(window_frame); } actual_window_frame.size.width = NSWidth(window_frame); // Do not expand if it's going to cover mouse location. if (NSPointInRect([NSEvent mouseLocation], actual_window_frame)) return; [NSAnimationContext beginGrouping]; [[NSAnimationContext currentContext] setDuration:kExpansionDuration]; [[window_ animator] setFrame:actual_window_frame display:YES]; [NSAnimationContext endGrouping]; } void StatusBubbleMac::UpdateSizeAndPosition() { if (!window_) return; [window_ setFrame:CalculateWindowFrame(/*expand=*/false) display:YES]; } void StatusBubbleMac::SwitchParentWindow(NSWindow* parent) { DCHECK(parent); DCHECK(is_attached()); Detach(); parent_ = parent; Attach(); UpdateSizeAndPosition(); } NSRect StatusBubbleMac::CalculateWindowFrame(bool expanded_width) { DCHECK(parent_); NSRect screenRect; if ([delegate_ respondsToSelector:@selector(statusBubbleBaseFrame)]) { screenRect = [delegate_ statusBubbleBaseFrame]; screenRect.origin = [parent_ convertBaseToScreen:screenRect.origin]; } else { screenRect = [parent_ frame]; } NSSize size = NSMakeSize(0, kWindowHeight); size = [[parent_ contentView] convertSize:size toView:nil]; if (expanded_width) { size.width = screenRect.size.width; } else { size.width = kWindowWidthPercent * screenRect.size.width; } screenRect.size = size; return screenRect; }