1 // Copyright (c) 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 #include "ui/message_center/views/message_popup_collection.h" 6 7 #include <set> 8 9 #include "base/bind.h" 10 #include "base/i18n/rtl.h" 11 #include "base/logging.h" 12 #include "base/memory/weak_ptr.h" 13 #include "base/run_loop.h" 14 #include "base/time/time.h" 15 #include "base/timer/timer.h" 16 #include "ui/base/accessibility/accessibility_types.h" 17 #include "ui/gfx/animation/animation_delegate.h" 18 #include "ui/gfx/animation/slide_animation.h" 19 #include "ui/gfx/screen.h" 20 #include "ui/message_center/message_center.h" 21 #include "ui/message_center/message_center_style.h" 22 #include "ui/message_center/message_center_tray.h" 23 #include "ui/message_center/message_center_util.h" 24 #include "ui/message_center/notification.h" 25 #include "ui/message_center/notification_list.h" 26 #include "ui/message_center/views/notification_view.h" 27 #include "ui/message_center/views/toast_contents_view.h" 28 #include "ui/views/background.h" 29 #include "ui/views/layout/fill_layout.h" 30 #include "ui/views/view.h" 31 #include "ui/views/views_delegate.h" 32 #include "ui/views/widget/widget.h" 33 #include "ui/views/widget/widget_delegate.h" 34 35 namespace message_center { 36 namespace { 37 38 // Timeout between the last user-initiated close of the toast and the moment 39 // when normal layout/update of the toast stack continues. If the last toast was 40 // just closed, the timeout is shorter. 41 const int kMouseExitedDeferTimeoutMs = 200; 42 43 // The margin between messages (and between the anchor unless 44 // first_item_has_no_margin was specified). 45 const int kToastMarginY = kMarginBetweenItems; 46 #if defined(OS_CHROMEOS) 47 const int kToastMarginX = 3; 48 #else 49 const int kToastMarginX = kMarginBetweenItems; 50 #endif 51 52 53 // If there should be no margin for the first item, this value needs to be 54 // substracted to flush the message to the shelf (the width of the border + 55 // shadow). 56 const int kNoToastMarginBorderAndShadowOffset = 2; 57 58 } // namespace. 59 60 MessagePopupCollection::MessagePopupCollection(gfx::NativeView parent, 61 MessageCenter* message_center, 62 MessageCenterTray* tray, 63 bool first_item_has_no_margin) 64 : parent_(parent), 65 message_center_(message_center), 66 tray_(tray), 67 defer_counter_(0), 68 latest_toast_entered_(NULL), 69 user_is_closing_toasts_by_clicking_(false), 70 first_item_has_no_margin_(first_item_has_no_margin), 71 weak_factory_(this) { 72 DCHECK(message_center_); 73 defer_timer_.reset(new base::OneShotTimer<MessagePopupCollection>); 74 message_center_->AddObserver(this); 75 gfx::Screen* screen = NULL; 76 gfx::Display display; 77 if (!parent_) { 78 // On Win+Aura, we don't have a parent since the popups currently show up 79 // on the Windows desktop, not in the Aura/Ash desktop. This code will 80 // display the popups on the primary display. 81 screen = gfx::Screen::GetNativeScreen(); 82 display = screen->GetPrimaryDisplay(); 83 } else { 84 screen = gfx::Screen::GetScreenFor(parent_); 85 display = screen->GetDisplayNearestWindow(parent_); 86 } 87 screen->AddObserver(this); 88 89 display_id_ = display.id(); 90 work_area_ = display.work_area(); 91 ComputePopupAlignment(work_area_, display.bounds()); 92 93 // We should not update before work area and popup alignment are computed. 94 DoUpdateIfPossible(); 95 } 96 97 MessagePopupCollection::~MessagePopupCollection() { 98 weak_factory_.InvalidateWeakPtrs(); 99 100 gfx::Screen* screen = parent_ ? 101 gfx::Screen::GetScreenFor(parent_) : gfx::Screen::GetNativeScreen(); 102 screen->RemoveObserver(this); 103 message_center_->RemoveObserver(this); 104 105 CloseAllWidgets(); 106 } 107 108 void MessagePopupCollection::ClickOnNotification( 109 const std::string& notification_id) { 110 message_center_->ClickOnNotification(notification_id); 111 } 112 113 void MessagePopupCollection::RemoveNotification( 114 const std::string& notification_id, 115 bool by_user) { 116 message_center_->RemoveNotification(notification_id, by_user); 117 } 118 119 void MessagePopupCollection::DisableNotificationsFromThisSource( 120 const NotifierId& notifier_id) { 121 message_center_->DisableNotificationsByNotifier(notifier_id); 122 } 123 124 void MessagePopupCollection::ShowNotifierSettingsBubble() { 125 tray_->ShowNotifierSettingsBubble(); 126 } 127 128 bool MessagePopupCollection::HasClickedListener( 129 const std::string& notification_id) { 130 return message_center_->HasClickedListener(notification_id); 131 } 132 133 void MessagePopupCollection::ClickOnNotificationButton( 134 const std::string& notification_id, 135 int button_index) { 136 message_center_->ClickOnNotificationButton(notification_id, button_index); 137 } 138 139 void MessagePopupCollection::ExpandNotification( 140 const std::string& notification_id) { 141 message_center_->ExpandNotification(notification_id); 142 } 143 144 void MessagePopupCollection::GroupBodyClicked( 145 const std::string& last_notification_id) { 146 // No group views in popup collection. 147 NOTREACHED(); 148 } 149 150 // When clicked on the "N more" button, perform some reasonable action. 151 // TODO(dimich): find out what the reasonable action could be. 152 void MessagePopupCollection::ExpandGroup(const NotifierId& notifier_id) { 153 // No group views in popup collection. 154 NOTREACHED(); 155 } 156 157 void MessagePopupCollection::RemoveGroup(const NotifierId& notifier_id) { 158 // No group views in popup collection. 159 NOTREACHED(); 160 } 161 162 void MessagePopupCollection::MarkAllPopupsShown() { 163 std::set<std::string> closed_ids = CloseAllWidgets(); 164 for (std::set<std::string>::iterator iter = closed_ids.begin(); 165 iter != closed_ids.end(); iter++) { 166 message_center_->MarkSinglePopupAsShown(*iter, false); 167 } 168 } 169 170 void MessagePopupCollection::UpdateWidgets() { 171 NotificationList::PopupNotifications popups = 172 message_center_->GetPopupNotifications(); 173 174 if (popups.empty()) { 175 CloseAllWidgets(); 176 return; 177 } 178 179 bool top_down = alignment_ & POPUP_ALIGNMENT_TOP; 180 int base = GetBaseLine(toasts_.empty() ? NULL : toasts_.back()); 181 182 // Iterate in the reverse order to keep the oldest toasts on screen. Newer 183 // items may be ignored if there are no room to place them. 184 for (NotificationList::PopupNotifications::const_reverse_iterator iter = 185 popups.rbegin(); iter != popups.rend(); ++iter) { 186 if (FindToast((*iter)->id())) 187 continue; 188 189 bool expanded = true; 190 if (IsExperimentalNotificationUIEnabled()) 191 expanded = (*iter)->is_expanded(); 192 NotificationView* view = 193 NotificationView::Create(NULL, 194 *(*iter), 195 expanded, 196 true); // Create top-level notification. 197 int view_height = ToastContentsView::GetToastSizeForView(view).height(); 198 int height_available = top_down ? work_area_.bottom() - base : base; 199 200 if (height_available - view_height - kToastMarginY < 0) { 201 delete view; 202 break; 203 } 204 205 ToastContentsView* toast = 206 new ToastContentsView((*iter)->id(), weak_factory_.GetWeakPtr()); 207 // There will be no contents already since this is a new ToastContentsView. 208 toast->SetContents(view, /*a11y_feedback_for_updates=*/false); 209 toasts_.push_back(toast); 210 view->set_controller(toast); 211 212 gfx::Size preferred_size = toast->GetPreferredSize(); 213 gfx::Point origin(GetToastOriginX(gfx::Rect(preferred_size)), base); 214 // The toast slides in from the edge of the screen horizontally. 215 if (alignment_ & POPUP_ALIGNMENT_LEFT) 216 origin.set_x(origin.x() - preferred_size.width()); 217 else 218 origin.set_x(origin.x() + preferred_size.width()); 219 if (top_down) 220 origin.set_y(origin.y() + view_height); 221 222 toast->RevealWithAnimation(origin); 223 224 // Shift the base line to be a few pixels above the last added toast or (few 225 // pixels below last added toast if top-aligned). 226 if (top_down) 227 base += view_height + kToastMarginY; 228 else 229 base -= view_height + kToastMarginY; 230 231 if (views::ViewsDelegate::views_delegate) { 232 views::ViewsDelegate::views_delegate->NotifyAccessibilityEvent( 233 toast, ui::AccessibilityTypes::EVENT_ALERT); 234 } 235 236 message_center_->DisplayedNotification((*iter)->id()); 237 } 238 } 239 240 void MessagePopupCollection::OnMouseEntered(ToastContentsView* toast_entered) { 241 // Sometimes we can get two MouseEntered/MouseExited in a row when animating 242 // toasts. So we need to keep track of which one is the currently active one. 243 latest_toast_entered_ = toast_entered; 244 245 message_center_->PausePopupTimers(); 246 247 if (user_is_closing_toasts_by_clicking_) 248 defer_timer_->Stop(); 249 } 250 251 void MessagePopupCollection::OnMouseExited(ToastContentsView* toast_exited) { 252 // If we're exiting a toast after entering a different toast, then ignore 253 // this mouse event. 254 if (toast_exited != latest_toast_entered_) 255 return; 256 latest_toast_entered_ = NULL; 257 258 if (user_is_closing_toasts_by_clicking_) { 259 defer_timer_->Start( 260 FROM_HERE, 261 base::TimeDelta::FromMilliseconds(kMouseExitedDeferTimeoutMs), 262 this, 263 &MessagePopupCollection::OnDeferTimerExpired); 264 } else { 265 message_center_->RestartPopupTimers(); 266 } 267 } 268 269 std::set<std::string> MessagePopupCollection::CloseAllWidgets() { 270 std::set<std::string> closed_toast_ids; 271 272 while (!toasts_.empty()) { 273 ToastContentsView* toast = toasts_.front(); 274 toasts_.pop_front(); 275 closed_toast_ids.insert(toast->id()); 276 277 OnMouseExited(toast); 278 279 // CloseWithAnimation will cause the toast to forget about |this| so it is 280 // required when we forget a toast. 281 toast->CloseWithAnimation(); 282 } 283 284 return closed_toast_ids; 285 } 286 287 void MessagePopupCollection::ForgetToast(ToastContentsView* toast) { 288 toasts_.remove(toast); 289 OnMouseExited(toast); 290 } 291 292 void MessagePopupCollection::RemoveToast(ToastContentsView* toast, 293 bool mark_as_shown) { 294 ForgetToast(toast); 295 296 toast->CloseWithAnimation(); 297 298 if (mark_as_shown) 299 message_center_->MarkSinglePopupAsShown(toast->id(), false); 300 } 301 302 int MessagePopupCollection::GetToastOriginX(const gfx::Rect& toast_bounds) 303 const { 304 #if defined(OS_CHROMEOS) 305 // In ChromeOS, RTL UI language mirrors the whole desktop layout, so the toast 306 // widgets should be at the bottom-left instead of bottom right. 307 if (base::i18n::IsRTL()) 308 return work_area_.x() + kToastMarginX; 309 #endif 310 if (alignment_ & POPUP_ALIGNMENT_LEFT) 311 return work_area_.x() + kToastMarginX; 312 return work_area_.right() - kToastMarginX - toast_bounds.width(); 313 } 314 315 void MessagePopupCollection::RepositionWidgets() { 316 bool top_down = alignment_ & POPUP_ALIGNMENT_TOP; 317 int base = GetBaseLine(NULL); // We don't want to position relative to last 318 // toast - we want re-position. 319 320 for (Toasts::const_iterator iter = toasts_.begin(); iter != toasts_.end();) { 321 Toasts::const_iterator curr = iter++; 322 gfx::Rect bounds((*curr)->bounds()); 323 bounds.set_x(GetToastOriginX(bounds)); 324 bounds.set_y(alignment_ & POPUP_ALIGNMENT_TOP ? base 325 : base - bounds.height()); 326 327 // The notification may scrolls the boundary of the screen due to image 328 // load and such notifications should disappear. Do not call 329 // CloseWithAnimation, we don't want to show the closing animation, and we 330 // don't want to mark such notifications as shown. See crbug.com/233424 331 if ((top_down ? work_area_.bottom() - bounds.bottom() : bounds.y()) >= 0) 332 (*curr)->SetBoundsWithAnimation(bounds); 333 else 334 RemoveToast(*curr, /*mark_as_shown=*/false); 335 336 // Shift the base line to be a few pixels above the last added toast or (few 337 // pixels below last added toast if top-aligned). 338 if (top_down) 339 base += bounds.height() + kToastMarginY; 340 else 341 base -= bounds.height() + kToastMarginY; 342 } 343 } 344 345 void MessagePopupCollection::RepositionWidgetsWithTarget() { 346 if (toasts_.empty()) 347 return; 348 349 bool top_down = alignment_ & POPUP_ALIGNMENT_TOP; 350 351 // Nothing to do if there are no widgets above target if bottom-aligned or no 352 // widgets below target if top-aligned. 353 if (top_down ? toasts_.back()->origin().y() < target_top_edge_ 354 : toasts_.back()->origin().y() > target_top_edge_) 355 return; 356 357 Toasts::reverse_iterator iter = toasts_.rbegin(); 358 for (; iter != toasts_.rend(); ++iter) { 359 // We only reposition widgets above target if bottom-aligned or widgets 360 // below target if top-aligned. 361 if (top_down ? (*iter)->origin().y() < target_top_edge_ 362 : (*iter)->origin().y() > target_top_edge_) 363 break; 364 } 365 --iter; 366 367 // Slide length is the number of pixels the widgets should move so that their 368 // bottom edge (top-edge if top-aligned) touches the target. 369 int slide_length = std::abs(target_top_edge_ - (*iter)->origin().y()); 370 for (;; --iter) { 371 gfx::Rect bounds((*iter)->bounds()); 372 373 // If top-aligned, shift widgets upwards by slide_length. If bottom-aligned, 374 // shift them downwards by slide_length. 375 if (top_down) 376 bounds.set_y(bounds.y() - slide_length); 377 else 378 bounds.set_y(bounds.y() + slide_length); 379 (*iter)->SetBoundsWithAnimation(bounds); 380 381 if (iter == toasts_.rbegin()) 382 break; 383 } 384 } 385 386 void MessagePopupCollection::ComputePopupAlignment(gfx::Rect work_area, 387 gfx::Rect screen_bounds) { 388 // If the taskbar is at the top, render notifications top down. Some platforms 389 // like Gnome can have taskbars at top and bottom. In this case it's more 390 // likely that the systray is on the top one. 391 alignment_ = work_area.y() > screen_bounds.y() ? POPUP_ALIGNMENT_TOP 392 : POPUP_ALIGNMENT_BOTTOM; 393 394 // If the taskbar is on the left show the notifications on the left. Otherwise 395 // show it on right since it's very likely that the systray is on the right if 396 // the taskbar is on the top or bottom. 397 // Since on some platforms like Ubuntu Unity there's also a launcher along 398 // with a taskbar (panel), we need to check that there is really nothing at 399 // the top before concluding that the taskbar is at the left. 400 alignment_ = static_cast<PopupAlignment>( 401 alignment_ | 402 ((work_area.x() > screen_bounds.x() && work_area.y() == screen_bounds.y()) 403 ? POPUP_ALIGNMENT_LEFT 404 : POPUP_ALIGNMENT_RIGHT)); 405 } 406 407 int MessagePopupCollection::GetBaseLine(ToastContentsView* last_toast) const { 408 bool top_down = alignment_ & POPUP_ALIGNMENT_TOP; 409 int base; 410 411 if (top_down) { 412 if (!last_toast) { 413 base = work_area_.y(); 414 if (!first_item_has_no_margin_) 415 base += kToastMarginY; 416 else 417 base -= kNoToastMarginBorderAndShadowOffset; 418 } else { 419 base = toasts_.back()->bounds().bottom() + kToastMarginY; 420 } 421 } else { 422 if (!last_toast) { 423 base = work_area_.bottom(); 424 if (!first_item_has_no_margin_) 425 base -= kToastMarginY; 426 else 427 base += kNoToastMarginBorderAndShadowOffset; 428 } else { 429 base = toasts_.back()->origin().y() - kToastMarginY; 430 } 431 } 432 return base; 433 } 434 435 void MessagePopupCollection::OnNotificationAdded( 436 const std::string& notification_id) { 437 DoUpdateIfPossible(); 438 } 439 440 void MessagePopupCollection::OnNotificationRemoved( 441 const std::string& notification_id, 442 bool by_user) { 443 // Find a toast. 444 Toasts::const_iterator iter = toasts_.begin(); 445 for (; iter != toasts_.end(); ++iter) { 446 if ((*iter)->id() == notification_id) 447 break; 448 } 449 if (iter == toasts_.end()) 450 return; 451 452 target_top_edge_ = (*iter)->bounds().y(); 453 if (by_user && !user_is_closing_toasts_by_clicking_) { 454 // [Re] start a timeout after which the toasts re-position to their 455 // normal locations after tracking the mouse pointer for easy deletion. 456 // This provides a period of time when toasts are easy to remove because 457 // they re-position themselves to have Close button right under the mouse 458 // pointer. If the user continue to remove the toasts, the delay is reset. 459 // Once user stopped removing the toasts, the toasts re-populate/rearrange 460 // after the specified delay. 461 user_is_closing_toasts_by_clicking_ = true; 462 IncrementDeferCounter(); 463 } 464 465 // CloseWithAnimation ultimately causes a call to RemoveToast, which calls 466 // OnMouseExited. This means that |user_is_closing_toasts_by_clicking_| must 467 // have been set before this call, otherwise it will remain true even after 468 // the toast is closed, since the defer timer won't be started. 469 RemoveToast(*iter, /*mark_as_shown=*/true); 470 471 if (by_user) 472 RepositionWidgetsWithTarget(); 473 } 474 475 void MessagePopupCollection::OnDeferTimerExpired() { 476 user_is_closing_toasts_by_clicking_ = false; 477 DecrementDeferCounter(); 478 479 message_center_->RestartPopupTimers(); 480 } 481 482 void MessagePopupCollection::OnNotificationUpdated( 483 const std::string& notification_id) { 484 // Find a toast. 485 Toasts::const_iterator toast_iter = toasts_.begin(); 486 for (; toast_iter != toasts_.end(); ++toast_iter) { 487 if ((*toast_iter)->id() == notification_id) 488 break; 489 } 490 if (toast_iter == toasts_.end()) 491 return; 492 493 NotificationList::PopupNotifications notifications = 494 message_center_->GetPopupNotifications(); 495 bool updated = false; 496 497 for (NotificationList::PopupNotifications::iterator iter = 498 notifications.begin(); iter != notifications.end(); ++iter) { 499 if ((*iter)->id() != notification_id) 500 continue; 501 502 bool expanded = true; 503 if (IsExperimentalNotificationUIEnabled()) 504 expanded = (*iter)->is_expanded(); 505 506 const RichNotificationData& optional_fields = 507 (*iter)->rich_notification_data(); 508 bool a11y_feedback_for_updates = 509 optional_fields.should_make_spoken_feedback_for_popup_updates; 510 511 NotificationView* view = 512 NotificationView::Create(*toast_iter, 513 *(*iter), 514 expanded, 515 true); // Create top-level notification. 516 (*toast_iter)->SetContents(view, a11y_feedback_for_updates); 517 updated = true; 518 } 519 520 // OnNotificationUpdated() can be called when a notification is excluded from 521 // the popup notification list but still remains in the full notification 522 // list. In that case the widget for the notification has to be closed here. 523 if (!updated) 524 RemoveToast(*toast_iter, /*mark_as_shown=*/true); 525 526 if (user_is_closing_toasts_by_clicking_) 527 RepositionWidgetsWithTarget(); 528 else 529 DoUpdateIfPossible(); 530 } 531 532 ToastContentsView* MessagePopupCollection::FindToast( 533 const std::string& notification_id) const { 534 for (Toasts::const_iterator iter = toasts_.begin(); iter != toasts_.end(); 535 ++iter) { 536 if ((*iter)->id() == notification_id) 537 return *iter; 538 } 539 return NULL; 540 } 541 542 void MessagePopupCollection::IncrementDeferCounter() { 543 defer_counter_++; 544 } 545 546 void MessagePopupCollection::DecrementDeferCounter() { 547 defer_counter_--; 548 DCHECK(defer_counter_ >= 0); 549 DoUpdateIfPossible(); 550 } 551 552 // This is the main sequencer of tasks. It does a step, then waits for 553 // all started transitions to play out before doing the next step. 554 // First, remove all expired toasts. 555 // Then, reposition widgets (the reposition on close happens before all 556 // deferred tasks are even able to run) 557 // Then, see if there is vacant space for new toasts. 558 void MessagePopupCollection::DoUpdateIfPossible() { 559 if (defer_counter_ > 0) 560 return; 561 562 RepositionWidgets(); 563 564 if (defer_counter_ > 0) 565 return; 566 567 // Reposition could create extra space which allows additional widgets. 568 UpdateWidgets(); 569 570 if (defer_counter_ > 0) 571 return; 572 573 // Test support. Quit the test run loop when no more updates are deferred, 574 // meaining th echeck for updates did not cause anything to change so no new 575 // transition animations were started. 576 if (run_loop_for_test_.get()) 577 run_loop_for_test_->Quit(); 578 } 579 580 void MessagePopupCollection::SetDisplayInfo(const gfx::Rect& work_area, 581 const gfx::Rect& screen_bounds) { 582 if (work_area_ == work_area) 583 return; 584 585 work_area_ = work_area; 586 ComputePopupAlignment(work_area, screen_bounds); 587 RepositionWidgets(); 588 } 589 590 void MessagePopupCollection::OnDisplayBoundsChanged( 591 const gfx::Display& display) { 592 if (display.id() != display_id_) 593 return; 594 595 SetDisplayInfo(display.work_area(), display.bounds()); 596 } 597 598 void MessagePopupCollection::OnDisplayAdded(const gfx::Display& new_display) { 599 } 600 601 void MessagePopupCollection::OnDisplayRemoved(const gfx::Display& old_display) { 602 } 603 604 views::Widget* MessagePopupCollection::GetWidgetForTest(const std::string& id) 605 const { 606 for (Toasts::const_iterator iter = toasts_.begin(); iter != toasts_.end(); 607 ++iter) { 608 if ((*iter)->id() == id) 609 return (*iter)->GetWidget(); 610 } 611 return NULL; 612 } 613 614 void MessagePopupCollection::CreateRunLoopForTest() { 615 run_loop_for_test_.reset(new base::RunLoop()); 616 } 617 618 void MessagePopupCollection::WaitForTest() { 619 run_loop_for_test_->Run(); 620 run_loop_for_test_.reset(); 621 } 622 623 gfx::Rect MessagePopupCollection::GetToastRectAt(size_t index) const { 624 DCHECK(defer_counter_ == 0) << "Fetching the bounds with animations active."; 625 size_t i = 0; 626 for (Toasts::const_iterator iter = toasts_.begin(); iter != toasts_.end(); 627 ++iter) { 628 if (i++ == index) { 629 views::Widget* widget = (*iter)->GetWidget(); 630 if (widget) 631 return widget->GetWindowBoundsInScreen(); 632 break; 633 } 634 } 635 return gfx::Rect(); 636 } 637 638 } // namespace message_center 639