blob: a286c4d3428c15bb5af3b92493267e1a5defbff2 [file] [log] [blame]
// 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/cocoa/status_bubble_mac.h"
#include <limits>
#include "app/text_elider.h"
#include "base/compiler_specific.h"
#include "base/message_loop.h"
#include "base/string_util.h"
#include "base/sys_string_conversions.h"
#import "chrome/browser/cocoa/bubble_view.h"
#include "gfx/point.h"
#include "googleurl/src/gurl.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"
namespace {
const int kWindowHeight = 18;
// The width of the bubble in relation to the width of the parent window.
const double 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();
} // 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)),
parent_(parent),
delegate_(delegate),
window_(nil),
status_text_(nil),
url_text_(nil),
state_(kBubbleHidden),
immediate_(false) {
}
StatusBubbleMac::~StatusBubbleMac() {
Hide();
if (window_) {
[[[window_ animationForKey:kFadeAnimationKey] delegate] invalidate];
Detach();
[window_ release];
window_ = nil;
}
}
void StatusBubbleMac::SetStatus(const std::wstring& status) {
Create();
SetText(status, false);
}
void StatusBubbleMac::SetURL(const GURL& url, const std::wstring& languages) {
Create();
NSRect frame = [window_ frame];
int text_width = static_cast<int>(frame.size.width -
kBubbleViewTextPositionX -
kTextPadding);
NSFont* font = [[window_ contentView] font];
gfx::Font font_chr =
gfx::Font::CreateFont(base::SysNSStringToWide([font fontName]),
[font pointSize]);
std::wstring status = gfx::ElideUrl(url, font_chr, text_width, languages);
SetText(status, true);
}
void StatusBubbleMac::SetText(const std::wstring& 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::SysWideToNSString(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();
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);
}
[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
// Get the normal position of the frame.
NSRect window_frame = [window_ frame];
window_frame.origin = [parent_ frame].origin;
bool isShelfVisible = false;
// Adjust the position to sit on top of download and extension shelves.
// |delegate_| can be nil during unit tests.
if ([delegate_ respondsToSelector:@selector(verticalOffsetForStatusBubble)]) {
window_frame.origin.y += [delegate_ verticalOffsetForStatusBubble];
isShelfVisible = [delegate_ verticalOffsetForStatusBubble] > 0;
}
// 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 (isOnScreen && !isShelfVisible) {
// 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 {
// The bubble will obscure the download shelf. Move the bubble to the
// right and reset Y offset_ to zero.
[[window_ contentView] setCornerFlags:kRoundedTopLeftCorner];
// Subtract border width + bubble width.
window_frame.origin.x += NSWidth([parent_ frame]) - NSWidth(window_frame);
}
} else {
[[window_ contentView] setCornerFlags:kRoundedTopRightCorner];
}
[window_ setFrame:window_frame display:YES];
}
void StatusBubbleMac::UpdateDownloadShelfVisibility(bool visible) {
}
void StatusBubbleMac::Create() {
if (window_)
return;
// TODO(avi):fix this for RTL
NSRect window_rect = CalculateWindowFrame();
// initWithContentRect has origin in screen coords and size in scaled window
// coordinates.
window_rect.size =
[[parent_ contentView] convertSize:window_rect.size fromView:nil];
window_ = [[NSWindow alloc] initWithContentRect:window_rect
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];
// Don't |Attach()| since we don't know the appropriate state; let the
// |SetState()| call do that.
[view setCornerFlags:kRoundedTopRightCorner];
MouseMoved(gfx::Point(), false);
}
void StatusBubbleMac::Attach() {
// This method may be called several times during the process of creating or
// showing a status bubble to attach the bubble to its parent window.
if (!is_attached())
[parent_ addChildWindow:window_ ordered:NSWindowAbove];
}
void StatusBubbleMac::Detach() {
// This method may be called several times in the process of hiding or
// destroying a status bubble.
if (is_attached()) {
[parent_ removeChildWindow:window_]; // See crbug.com/28107 ...
[window_ orderOut:nil]; // ... and crbug.com/29054.
}
}
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) {
// We must be hidden or attached, but not both.
DCHECK((state_ == kBubbleHidden) ^ is_attached());
if (state == state_)
return;
if (state == kBubbleHidden)
Detach();
else
Attach();
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() {
// Note that |SetState()| will |Attach()| or |Detach()| as required.
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::UpdateSizeAndPosition() {
if (!window_)
return;
[window_ setFrame:CalculateWindowFrame() display:YES];
}
void StatusBubbleMac::SwitchParentWindow(NSWindow* parent) {
DCHECK(parent);
// If not attached, just update our member variable and position.
if (!is_attached()) {
parent_ = parent;
[[window_ contentView] setThemeProvider:parent];
UpdateSizeAndPosition();
return;
}
Detach();
parent_ = parent;
[[window_ contentView] setThemeProvider:parent];
Attach();
UpdateSizeAndPosition();
}
NSRect StatusBubbleMac::CalculateWindowFrame() {
DCHECK(parent_);
NSSize size = NSMakeSize(0, kWindowHeight);
size = [[parent_ contentView] convertSize:size toView:nil];
NSRect rect = [parent_ frame];
rect.size.height = size.height;
rect.size.width = static_cast<int>(kWindowWidthPercent * rect.size.width);
return rect;
}