blob: 05a6bdde3f9a0c949e08968d0dfa931eb9082fb3 [file] [log] [blame]
// Copyright (c) 2013 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 "ui/message_center/cocoa/popup_collection.h"
#import "ui/message_center/cocoa/notification_controller.h"
#import "ui/message_center/cocoa/popup_controller.h"
#include "ui/message_center/message_center.h"
#include "ui/message_center/message_center_observer.h"
#include "ui/message_center/message_center_style.h"
const float kAnimationDuration = 0.2;
@interface MCPopupCollection (Private)
// Returns the primary screen's visible frame rectangle.
- (NSRect)screenFrame;
// Shows a popup, if there is room on-screen, for the given notification.
// Returns YES if the notification was actually displayed.
- (BOOL)addNotification:(const message_center::Notification*)notification;
// Updates the contents of the notification with the given ID.
- (void)updateNotification:(const std::string&)notificationID;
// Removes a popup from the screen and lays out new notifications that can
// now potentially fit on the screen.
- (void)removeNotification:(const std::string&)notificationID;
// Closes all the popups.
- (void)removeAllNotifications;
// Returns the index of the popup showing the notification with the given ID.
- (NSUInteger)indexOfPopupWithNotificationID:(const std::string&)notificationID;
// Repositions all popup notifications if needed.
- (void)layoutNotifications;
// Fits as many new notifications as possible on screen.
- (void)layoutNewNotifications;
// Process notifications pending to remove when no animation is being played.
- (void)processPendingRemoveNotifications;
// Process notifications pending to update when no animation is being played.
- (void)processPendingUpdateNotifications;
@end
namespace {
class PopupCollectionObserver : public message_center::MessageCenterObserver {
public:
PopupCollectionObserver(message_center::MessageCenter* message_center,
MCPopupCollection* popup_collection)
: message_center_(message_center),
popup_collection_(popup_collection) {
message_center_->AddObserver(this);
}
~PopupCollectionObserver() override { message_center_->RemoveObserver(this); }
void OnNotificationAdded(const std::string& notification_id) override {
[popup_collection_ layoutNewNotifications];
}
void OnNotificationRemoved(const std::string& notification_id,
bool user_id) override {
[popup_collection_ removeNotification:notification_id];
}
void OnNotificationUpdated(const std::string& notification_id) override {
[popup_collection_ updateNotification:notification_id];
}
private:
message_center::MessageCenter* message_center_; // Weak, global.
MCPopupCollection* popup_collection_; // Weak, owns this.
};
} // namespace
@implementation MCPopupCollection
- (id)initWithMessageCenter:(message_center::MessageCenter*)messageCenter {
if ((self = [super init])) {
messageCenter_ = messageCenter;
observer_.reset(new PopupCollectionObserver(messageCenter_, self));
popups_.reset([[NSMutableArray alloc] init]);
popupsBeingRemoved_.reset([[NSMutableArray alloc] init]);
popupAnimationDuration_ = kAnimationDuration;
}
return self;
}
- (void)dealloc {
[popupsBeingRemoved_ makeObjectsPerformSelector:
@selector(markPopupCollectionGone)];
[self removeAllNotifications];
[super dealloc];
}
- (BOOL)isAnimating {
return !animatingNotificationIDs_.empty();
}
- (NSTimeInterval)popupAnimationDuration {
return popupAnimationDuration_;
}
- (void)onPopupAnimationEnded:(const std::string&)notificationID {
NSUInteger index = [popupsBeingRemoved_ indexOfObjectPassingTest:
^BOOL(id popup, NSUInteger index, BOOL* stop) {
return [popup notificationID] == notificationID;
}];
if (index != NSNotFound)
[popupsBeingRemoved_ removeObjectAtIndex:index];
animatingNotificationIDs_.erase(notificationID);
if (![self isAnimating])
[self layoutNotifications];
// Give the testing code a chance to do something, i.e. quitting the test
// run loop.
if (![self isAnimating] && testingAnimationEndedCallback_)
testingAnimationEndedCallback_.get()();
}
// Testing API /////////////////////////////////////////////////////////////////
- (NSArray*)popups {
return popups_.get();
}
- (void)setScreenFrame:(NSRect)frame {
testingScreenFrame_ = frame;
}
- (void)setAnimationDuration:(NSTimeInterval)duration {
popupAnimationDuration_ = duration;
}
- (void)setAnimationEndedCallback:
(message_center::AnimationEndedCallback)callback {
testingAnimationEndedCallback_.reset(Block_copy(callback));
}
// Private /////////////////////////////////////////////////////////////////////
- (NSRect)screenFrame {
if (!NSIsEmptyRect(testingScreenFrame_))
return testingScreenFrame_;
return [[[NSScreen screens] firstObject] visibleFrame];
}
- (BOOL)addNotification:(const message_center::Notification*)notification {
// Wait till all existing animations end.
if ([self isAnimating])
return NO;
// The popup is owned by itself. It will be released at close.
MCPopupController* popup =
[[MCPopupController alloc] initWithNotification:notification
messageCenter:messageCenter_
popupCollection:self];
NSRect screenFrame = [self screenFrame];
NSRect popupFrame = [popup bounds];
CGFloat x = NSMaxX(screenFrame) - message_center::kMarginBetweenItems -
NSWidth(popupFrame);
CGFloat y = 0;
MCPopupController* bottomPopup = [popups_ lastObject];
if (!bottomPopup) {
y = NSMaxY(screenFrame);
} else {
y = NSMinY([bottomPopup bounds]);
}
y -= message_center::kMarginBetweenItems + NSHeight(popupFrame);
if (y > NSMinY(screenFrame)) {
animatingNotificationIDs_.insert(notification->id());
NSRect bounds = [popup bounds];
bounds.origin.x = x;
bounds.origin.y = y;
[popup showWithAnimation:bounds];
[popups_ addObject:popup];
messageCenter_->DisplayedNotification(
notification->id(), message_center::DISPLAY_SOURCE_POPUP);
return YES;
}
// The popup cannot fit on screen, so it has to be closed now.
[popup close];
return NO;
}
- (void)updateNotification:(const std::string&)notificationID {
// The notification may not be on screen. Create it if needed.
if ([self indexOfPopupWithNotificationID:notificationID] == NSNotFound) {
[self layoutNewNotifications];
return;
}
// Don't bother with the update if the notification is going to be removed.
if (pendingRemoveNotificationIDs_.find(notificationID) !=
pendingRemoveNotificationIDs_.end()) {
return;
}
pendingUpdateNotificationIDs_.insert(notificationID);
[self processPendingUpdateNotifications];
}
- (void)removeNotification:(const std::string&)notificationID {
// The notification may not be on screen.
if ([self indexOfPopupWithNotificationID:notificationID] == NSNotFound)
return;
// Don't bother with the update if the notification is going to be removed.
pendingUpdateNotificationIDs_.erase(notificationID);
pendingRemoveNotificationIDs_.insert(notificationID);
[self processPendingRemoveNotifications];
}
- (void)removeAllNotifications {
// In rare cases, the popup collection would be gone while an animation is
// still playing. For exmaple, the test code could show a new notification
// and dispose the collection immediately. Close the popup without animation
// when this is the case.
if ([self isAnimating])
[popups_ makeObjectsPerformSelector:@selector(close)];
else
[popups_ makeObjectsPerformSelector:@selector(closeWithAnimation)];
[popups_ makeObjectsPerformSelector:@selector(markPopupCollectionGone)];
[popups_ removeAllObjects];
}
- (NSUInteger)indexOfPopupWithNotificationID:
(const std::string&)notificationID {
return [popups_ indexOfObjectPassingTest:
^BOOL(id popup, NSUInteger index, BOOL* stop) {
return [popup notificationID] == notificationID;
}];
}
- (void)layoutNotifications {
// Wait till all existing animations end.
if ([self isAnimating])
return;
NSRect screenFrame = [self screenFrame];
// The popup starts at top-right corner.
CGFloat maxY = NSMaxY(screenFrame);
// Iterate all notifications and reposition each if needed. If one does not
// fit on screen, close it and any other on-screen popups that come after it.
NSUInteger removeAt = NSNotFound;
for (NSUInteger i = 0; i < [popups_ count]; ++i) {
MCPopupController* popup = [popups_ objectAtIndex:i];
NSRect oldFrame = [popup bounds];
NSRect frame = oldFrame;
frame.origin.y = maxY - message_center::kMarginBetweenItems -
NSHeight(frame);
// If this popup does not fit on screen, stop repositioning and close this
// and subsequent popups.
if (NSMinY(frame) < NSMinY(screenFrame)) {
removeAt = i;
break;
}
if (!NSEqualRects(frame, oldFrame)) {
[popup setBounds:frame];
animatingNotificationIDs_.insert([popup notificationID]);
}
// Set the new maximum Y to be the bottom of this notification.
maxY = NSMinY(frame);
}
if (removeAt != NSNotFound) {
// Remove any popups that are on screen but no longer fit.
while ([popups_ count] >= removeAt && [popups_ count]) {
[[popups_ lastObject] close];
[popups_ removeLastObject];
}
} else {
[self layoutNewNotifications];
}
[self processPendingRemoveNotifications];
[self processPendingUpdateNotifications];
}
- (void)layoutNewNotifications {
// Wait till all existing animations end.
if ([self isAnimating])
return;
// Display any new popups that can now fit on screen, starting from the
// oldest notification that has not been shown up.
const auto& allPopups = messageCenter_->GetPopupNotifications();
for (auto it = allPopups.rbegin(); it != allPopups.rend(); ++it) {
if ([self indexOfPopupWithNotificationID:(*it)->id()] == NSNotFound) {
// If there's no room left on screen to display notifications, stop
// trying.
if (![self addNotification:*it])
break;
}
}
}
- (void)processPendingRemoveNotifications {
// Wait till all existing animations end.
if ([self isAnimating])
return;
for (const auto& notificationID : pendingRemoveNotificationIDs_) {
NSUInteger index = [self indexOfPopupWithNotificationID:notificationID];
if (index != NSNotFound) {
[[popups_ objectAtIndex:index] closeWithAnimation];
animatingNotificationIDs_.insert(notificationID);
// Still need to track popup object and only remove it after the animation
// ends. We need to notify these objects that the collection is gone
// in the collection destructor.
[popupsBeingRemoved_ addObject:[popups_ objectAtIndex:index]];
[popups_ removeObjectAtIndex:index];
}
}
pendingRemoveNotificationIDs_.clear();
}
- (void)processPendingUpdateNotifications {
// Wait till all existing animations end.
if ([self isAnimating])
return;
if (pendingUpdateNotificationIDs_.empty())
return;
// Go through all model objects in the message center. If there is a replaced
// notification, the controller's current model object may be stale.
const auto& modelPopups = messageCenter_->GetPopupNotifications();
for (auto iter = modelPopups.begin(); iter != modelPopups.end(); ++iter) {
const std::string& notificationID = (*iter)->id();
// Does the notification need to be updated?
std::set<std::string>::iterator pendingUpdateIter =
pendingUpdateNotificationIDs_.find(notificationID);
if (pendingUpdateIter == pendingUpdateNotificationIDs_.end())
continue;
pendingUpdateNotificationIDs_.erase(pendingUpdateIter);
// Is the notification still on screen?
NSUInteger index = [self indexOfPopupWithNotificationID:notificationID];
if (index == NSNotFound)
continue;
MCPopupController* popup = [popups_ objectAtIndex:index];
CGFloat oldHeight =
NSHeight([[[popup notificationController] view] frame]);
CGFloat newHeight = NSHeight(
[[popup notificationController] updateNotification:*iter]);
// The notification has changed height. This requires updating the popup
// window.
if (oldHeight != newHeight) {
NSRect popupFrame = [popup bounds];
popupFrame.origin.y -= newHeight - oldHeight;
popupFrame.size.height += newHeight - oldHeight;
[popup setBounds:popupFrame];
animatingNotificationIDs_.insert([popup notificationID]);
}
}
// Notification update could be received when a notification is excluded from
// the popup notification list but still remains in the full notification
// list, as in clicking the popup. In that case, the popup should be closed.
for (auto iter = pendingUpdateNotificationIDs_.begin();
iter != pendingUpdateNotificationIDs_.end(); ++iter) {
pendingRemoveNotificationIDs_.insert(*iter);
}
pendingUpdateNotificationIDs_.clear();
// Start re-layout of all notifications, so that it readjusts the Y origin of
// all updated popups and any popups that come below them.
[self layoutNotifications];
}
@end