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
35namespace message_center {
36namespace {
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.
41const int kMouseExitedDeferTimeoutMs = 200;
42
43// The margin between messages (and between the anchor unless
44// first_item_has_no_margin was specified).
45const int kToastMarginY = kMarginBetweenItems;
46#if defined(OS_CHROMEOS)
47const int kToastMarginX = 3;
48#else
49const 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).
56const int kNoToastMarginBorderAndShadowOffset = 2;
57
58}  // namespace.
59
60MessagePopupCollection::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
97MessagePopupCollection::~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
108void MessagePopupCollection::ClickOnNotification(
109    const std::string& notification_id) {
110  message_center_->ClickOnNotification(notification_id);
111}
112
113void MessagePopupCollection::RemoveNotification(
114    const std::string& notification_id,
115    bool by_user) {
116  message_center_->RemoveNotification(notification_id, by_user);
117}
118
119void MessagePopupCollection::DisableNotificationsFromThisSource(
120    const NotifierId& notifier_id) {
121  message_center_->DisableNotificationsByNotifier(notifier_id);
122}
123
124void MessagePopupCollection::ShowNotifierSettingsBubble() {
125  tray_->ShowNotifierSettingsBubble();
126}
127
128bool MessagePopupCollection::HasClickedListener(
129    const std::string& notification_id) {
130  return message_center_->HasClickedListener(notification_id);
131}
132
133void MessagePopupCollection::ClickOnNotificationButton(
134    const std::string& notification_id,
135    int button_index) {
136  message_center_->ClickOnNotificationButton(notification_id, button_index);
137}
138
139void MessagePopupCollection::ExpandNotification(
140    const std::string& notification_id) {
141  message_center_->ExpandNotification(notification_id);
142}
143
144void 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.
152void MessagePopupCollection::ExpandGroup(const NotifierId& notifier_id) {
153  // No group views in popup collection.
154  NOTREACHED();
155}
156
157void MessagePopupCollection::RemoveGroup(const NotifierId& notifier_id) {
158  // No group views in popup collection.
159  NOTREACHED();
160}
161
162void 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
170void 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
240void 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
251void 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
269std::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
287void MessagePopupCollection::ForgetToast(ToastContentsView* toast) {
288  toasts_.remove(toast);
289  OnMouseExited(toast);
290}
291
292void 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
302int 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
315void 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
345void 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
386void 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
407int 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
435void MessagePopupCollection::OnNotificationAdded(
436    const std::string& notification_id) {
437  DoUpdateIfPossible();
438}
439
440void 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
475void MessagePopupCollection::OnDeferTimerExpired() {
476  user_is_closing_toasts_by_clicking_ = false;
477  DecrementDeferCounter();
478
479  message_center_->RestartPopupTimers();
480}
481
482void 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
532ToastContentsView* 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
542void MessagePopupCollection::IncrementDeferCounter() {
543  defer_counter_++;
544}
545
546void 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.
558void 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
580void 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
590void 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
598void MessagePopupCollection::OnDisplayAdded(const gfx::Display& new_display) {
599}
600
601void MessagePopupCollection::OnDisplayRemoved(const gfx::Display& old_display) {
602}
603
604views::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
614void MessagePopupCollection::CreateRunLoopForTest() {
615  run_loop_for_test_.reset(new base::RunLoop());
616}
617
618void MessagePopupCollection::WaitForTest() {
619  run_loop_for_test_->Run();
620  run_loop_for_test_.reset();
621}
622
623gfx::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