message_popup_collection.cc revision 558790d6acca3451cf3a6b497803a5f07d0bec58
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/base/animation/animation_delegate.h"
18#include "ui/base/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/notification.h"
23#include "ui/message_center/notification_list.h"
24#include "ui/message_center/views/notification_view.h"
25#include "ui/message_center/views/toast_contents_view.h"
26#include "ui/views/background.h"
27#include "ui/views/layout/fill_layout.h"
28#include "ui/views/view.h"
29#include "ui/views/views_delegate.h"
30#include "ui/views/widget/widget.h"
31#include "ui/views/widget/widget_delegate.h"
32
33namespace message_center {
34namespace {
35
36// Timeout between the last user-initiated close of the toast and the moment
37// when normal layout/update of the toast stack continues. If the last toast was
38// just closed, the timeout is shorter.
39const int kMouseExitedDeferTimeoutMs = 200;
40
41// The margin between messages (and between the anchor unless
42// first_item_has_no_margin was specified).
43const int kToastMarginY = kMarginBetweenItems;
44#if defined(OS_CHROMEOS)
45const int kToastMarginX = 3;
46#else
47const int kToastMarginX = kMarginBetweenItems;
48#endif
49
50
51// If there should be no margin for the first item, this value needs to be
52// substracted to flush the message to the shelf (the width of the border +
53// shadow).
54const int kNoToastMarginBorderAndShadowOffset = 2;
55
56}  // namespace.
57
58MessagePopupCollection::MessagePopupCollection(gfx::NativeView parent,
59                                               MessageCenter* message_center,
60                                               MessageCenterTray* tray,
61                                               bool first_item_has_no_margin)
62    : parent_(parent),
63      message_center_(message_center),
64      tray_(tray),
65      defer_counter_(0),
66      latest_toast_entered_(NULL),
67      user_is_closing_toasts_by_clicking_(false),
68      first_item_has_no_margin_(first_item_has_no_margin) {
69  DCHECK(message_center_);
70  defer_timer_.reset(new base::OneShotTimer<MessagePopupCollection>);
71  DoUpdateIfPossible();
72  message_center_->AddObserver(this);
73  gfx::Screen* screen = NULL;
74  if (!parent_) {
75    // On Win+Aura, we don't have a parent since the popups currently show up
76    // on the Windows desktop, not in the Aura/Ash desktop.  This code will
77    // display the popups on the primary display.
78    screen = gfx::Screen::GetNativeScreen();
79    gfx::Display display = screen->GetPrimaryDisplay();
80    display_id_ = display.id();
81    work_area_ = display.work_area();
82  } else {
83    screen = gfx::Screen::GetScreenFor(parent_);
84    gfx::Display display = screen->GetDisplayNearestWindow(parent_);
85    display_id_ = display.id();
86    work_area_ = display.work_area();
87  }
88  screen->AddObserver(this);
89}
90
91MessagePopupCollection::~MessagePopupCollection() {
92  gfx::Screen* screen = parent_ ?
93      gfx::Screen::GetScreenFor(parent_) : gfx::Screen::GetNativeScreen();
94  screen->RemoveObserver(this);
95  message_center_->RemoveObserver(this);
96  CloseAllWidgets();
97}
98
99void MessagePopupCollection::RemoveToast(ToastContentsView* toast) {
100  for (Toasts::iterator iter = toasts_.begin(); iter != toasts_.end(); ++iter) {
101    if ((*iter) == toast) {
102      toasts_.erase(iter);
103      break;
104    }
105  }
106}
107
108void MessagePopupCollection::UpdateWidgets() {
109  NotificationList::PopupNotifications popups =
110      message_center_->GetPopupNotifications();
111
112  if (popups.empty()) {
113    CloseAllWidgets();
114    return;
115  }
116
117  int bottom = toasts_.empty() ?
118      work_area_.bottom() : toasts_.back()->origin().y();
119
120  if (!first_item_has_no_margin_)
121    bottom -= kToastMarginY;
122  else
123    bottom += kNoToastMarginBorderAndShadowOffset;
124
125  // Iterate in the reverse order to keep the oldest toasts on screen. Newer
126  // items may be ignored if there are no room to place them.
127  for (NotificationList::PopupNotifications::const_reverse_iterator iter =
128           popups.rbegin(); iter != popups.rend(); ++iter) {
129    if (FindToast((*iter)->id()))
130      continue;
131
132    MessageView* view =
133        NotificationView::Create(*(*iter),
134                                 message_center_,
135                                 tray_,
136                                 true,  // Create expanded.
137                                 true); // Create top-level notification.
138    int view_height = ToastContentsView::GetToastSizeForView(view).height();
139    if (bottom - view_height - kToastMarginY < 0) {
140      delete view;
141      break;
142    }
143
144    ToastContentsView* toast = new ToastContentsView(
145        *iter, AsWeakPtr(), message_center_);
146    toast->CreateWidget(parent_);
147    toast->SetContents(view);
148    toasts_.push_back(toast);
149
150    gfx::Size preferred_size = toast->GetPreferredSize();
151    gfx::Point origin(
152        GetToastOriginX(gfx::Rect(preferred_size)) + preferred_size.width(),
153        bottom);
154    toast->RevealWithAnimation(origin);
155    bottom -= view_height + kToastMarginY;
156
157    message_center_->DisplayedNotification((*iter)->id());
158    if (views::ViewsDelegate::views_delegate) {
159      views::ViewsDelegate::views_delegate->NotifyAccessibilityEvent(
160          toast, ui::AccessibilityTypes::EVENT_ALERT);
161    }
162  }
163}
164
165void MessagePopupCollection::OnMouseEntered(ToastContentsView* toast_entered) {
166  // Sometimes we can get two MouseEntered/MouseExited in a row when animating
167  // toasts.  So we need to keep track of which one is the currently active one.
168  latest_toast_entered_ = toast_entered;
169
170  message_center_->PausePopupTimers();
171
172  if (user_is_closing_toasts_by_clicking_)
173    defer_timer_->Stop();
174}
175
176void MessagePopupCollection::OnMouseExited(ToastContentsView* toast_exited) {
177  // If we're exiting a toast after entering a different toast, then ignore
178  // this mouse event.
179  if (toast_exited != latest_toast_entered_)
180    return;
181  latest_toast_entered_ = NULL;
182
183  if (user_is_closing_toasts_by_clicking_) {
184    defer_timer_->Start(
185        FROM_HERE,
186        base::TimeDelta::FromMilliseconds(kMouseExitedDeferTimeoutMs),
187        this,
188        &MessagePopupCollection::OnDeferTimerExpired);
189  } else {
190    message_center_->RestartPopupTimers();
191  }
192}
193
194void MessagePopupCollection::CloseAllWidgets() {
195  for (Toasts::iterator iter = toasts_.begin(); iter != toasts_.end();) {
196    // the toast can be removed from toasts_ during CloseWithAnimation().
197    Toasts::iterator curiter = iter++;
198    (*curiter)->CloseWithAnimation(true);
199  }
200  DCHECK(toasts_.empty());
201}
202
203int MessagePopupCollection::GetToastOriginX(const gfx::Rect& toast_bounds) {
204#if defined(OS_CHROMEOS)
205  // In ChromeOS, RTL UI language mirrors the whole desktop layout, so the toast
206  // widgets should be at the bottom-left instead of bottom right.
207  if (base::i18n::IsRTL())
208    return work_area_.x() + kToastMarginX;
209#endif
210  return work_area_.right() - kToastMarginX - toast_bounds.width();
211}
212
213void MessagePopupCollection::RepositionWidgets() {
214  int bottom = work_area_.bottom();
215  if (!first_item_has_no_margin_)
216    bottom -= kToastMarginY;
217  else
218    bottom += kNoToastMarginBorderAndShadowOffset;
219
220  for (Toasts::iterator iter = toasts_.begin(); iter != toasts_.end();) {
221    Toasts::iterator curr = iter++;
222    gfx::Rect bounds((*curr)->bounds());
223    bounds.set_x(GetToastOriginX(bounds));
224    bounds.set_y(bottom - bounds.height());
225    // The notification may scrolls the top boundary of the screen due to image
226    // load and such notifications should disappear. Do not call
227    // CloseWithAnimation, we don't want to show the closing animation, and we
228    // don't want to mark such notifications as shown. See crbug.com/233424
229    if (bounds.y() >= 0)
230      (*curr)->SetBoundsWithAnimation(bounds);
231    else
232      (*curr)->CloseWithAnimation(false);
233    bottom -= bounds.height() + kToastMarginY;
234  }
235}
236
237void MessagePopupCollection::RepositionWidgetsWithTarget() {
238  if (toasts_.empty())
239    return;
240
241  // No widgets above.
242  if (toasts_.back()->origin().y() > target_top_edge_)
243    return;
244
245  Toasts::reverse_iterator iter = toasts_.rbegin();
246  for (; iter != toasts_.rend(); ++iter) {
247    if ((*iter)->origin().y() > target_top_edge_)
248      break;
249  }
250  --iter;
251  int slide_length = target_top_edge_ - (*iter)->origin().y();
252  for (; ; --iter) {
253    gfx::Rect bounds((*iter)->bounds());
254    bounds.set_y(bounds.y() + slide_length);
255    (*iter)->SetBoundsWithAnimation(bounds);
256
257    if (iter == toasts_.rbegin())
258      break;
259  }
260}
261
262void MessagePopupCollection::OnNotificationAdded(
263    const std::string& notification_id) {
264  DoUpdateIfPossible();
265}
266
267void MessagePopupCollection::OnNotificationRemoved(
268    const std::string& notification_id,
269    bool by_user) {
270  // Find a toast.
271  Toasts::iterator iter = toasts_.begin();
272  for (; iter != toasts_.end(); ++iter) {
273    if ((*iter)->id() == notification_id)
274      break;
275  }
276  if (iter == toasts_.end())
277    return;
278
279  target_top_edge_ = (*iter)->bounds().y();
280  (*iter)->CloseWithAnimation(true);
281  if (by_user) {
282    RepositionWidgetsWithTarget();
283    // [Re] start a timeout after which the toasts re-position to their
284    // normal locations after tracking the mouse pointer for easy deletion.
285    // This provides a period of time when toasts are easy to remove because
286    // they re-position themselves to have Close button right under the mouse
287    // pointer. If the user continue to remove the toasts, the delay is reset.
288    // Once user stopped removing the toasts, the toasts re-populate/rearrange
289    // after the specified delay.
290    if (!user_is_closing_toasts_by_clicking_) {
291      user_is_closing_toasts_by_clicking_ = true;
292      IncrementDeferCounter();
293    }
294  }
295}
296
297void MessagePopupCollection::OnDeferTimerExpired() {
298  user_is_closing_toasts_by_clicking_ = false;
299  DecrementDeferCounter();
300
301  message_center_->RestartPopupTimers();
302}
303
304void MessagePopupCollection::OnNotificationUpdated(
305    const std::string& notification_id) {
306  // Find a toast.
307  Toasts::iterator toast_iter = toasts_.begin();
308  for (; toast_iter != toasts_.end(); ++toast_iter) {
309    if ((*toast_iter)->id() == notification_id)
310      break;
311  }
312  if (toast_iter == toasts_.end())
313    return;
314
315  NotificationList::PopupNotifications notifications =
316      message_center_->GetPopupNotifications();
317  bool updated = false;
318
319  for (NotificationList::PopupNotifications::iterator iter =
320           notifications.begin(); iter != notifications.end(); ++iter) {
321    if ((*iter)->id() != notification_id)
322      continue;
323
324    MessageView* view =
325        NotificationView::Create(*(*iter),
326                                 message_center_,
327                                 tray_,
328                                 true,  // Create expanded.
329                                 true); // Create top-level notification.
330    (*toast_iter)->SetContents(view);
331    updated = true;
332  }
333
334  // OnNotificationUpdated() can be called when a notification is excluded from
335  // the popup notification list but still remains in the full notification
336  // list. In that case the widget for the notification has to be closed here.
337  if (!updated)
338    (*toast_iter)->CloseWithAnimation(true);
339
340  if (user_is_closing_toasts_by_clicking_)
341    RepositionWidgetsWithTarget();
342  else
343    DoUpdateIfPossible();
344}
345
346ToastContentsView* MessagePopupCollection::FindToast(
347    const std::string& notification_id) {
348  for (Toasts::iterator iter = toasts_.begin(); iter != toasts_.end(); ++iter) {
349    if ((*iter)->id() == notification_id)
350      return *iter;
351  }
352  return NULL;
353}
354
355void MessagePopupCollection::IncrementDeferCounter() {
356  defer_counter_++;
357}
358
359void MessagePopupCollection::DecrementDeferCounter() {
360  defer_counter_--;
361  DCHECK(defer_counter_ >= 0);
362  DoUpdateIfPossible();
363}
364
365// This is the main sequencer of tasks. It does a step, then waits for
366// all started transitions to play out before doing the next step.
367// First, remove all expired toasts.
368// Then, reposition widgets (the reposition on close happens before all
369// deferred tasks are even able to run)
370// Then, see if there is vacant space for new toasts.
371void MessagePopupCollection::DoUpdateIfPossible() {
372  if (defer_counter_ > 0)
373    return;
374
375  RepositionWidgets();
376
377  if (defer_counter_ > 0)
378    return;
379
380  // Reposition could create extra space which allows additional widgets.
381  UpdateWidgets();
382
383  if (defer_counter_ > 0)
384    return;
385
386  // Test support. Quit the test run loop when no more updates are deferred,
387  // meaining th echeck for updates did not cause anything to change so no new
388  // transition animations were started.
389  if (run_loop_for_test_.get())
390    run_loop_for_test_->Quit();
391}
392
393void MessagePopupCollection::SetWorkArea(const gfx::Rect& work_area) {
394  if (work_area_ == work_area)
395    return;
396
397  work_area_ = work_area;
398  RepositionWidgets();
399}
400
401void MessagePopupCollection::OnDisplayBoundsChanged(
402    const gfx::Display& display) {
403  if (display.id() != display_id_)
404    return;
405
406  SetWorkArea(display.work_area());
407}
408
409void MessagePopupCollection::OnDisplayAdded(const gfx::Display& new_display) {
410}
411
412void MessagePopupCollection::OnDisplayRemoved(const gfx::Display& old_display) {
413}
414
415views::Widget* MessagePopupCollection::GetWidgetForTest(const std::string& id) {
416  for (Toasts::iterator iter = toasts_.begin(); iter != toasts_.end(); ++iter) {
417    if ((*iter)->id() == id)
418      return (*iter)->GetWidget();
419  }
420  return NULL;
421}
422
423void MessagePopupCollection::RunLoopForTest() {
424  run_loop_for_test_.reset(new base::RunLoop());
425  run_loop_for_test_->Run();
426  run_loop_for_test_.reset();
427}
428
429gfx::Rect MessagePopupCollection::GetToastRectAt(size_t index) {
430  DCHECK(defer_counter_ == 0) << "Fetching the bounds with animations active.";
431  size_t i = 0;
432  for (Toasts::iterator iter = toasts_.begin(); iter != toasts_.end(); ++iter) {
433    if (i++ == index) {
434      views::Widget* widget = (*iter)->GetWidget();
435      if (widget)
436        return widget->GetWindowBoundsInScreen();
437      break;
438    }
439  }
440  return gfx::Rect();
441}
442
443}  // namespace message_center
444