exo: Prompt to use Overview to exit pointer lock/fullscreen in Borealis

Instead of prompting to hold Esc to exit fullscreen, prompt to press
the Overview button. Remove the hold-Esc check for Borealis, but keep
it for Parallels.

Also display a similar prompt for pointer lock, in windowed mode,
again for Borealis only (at least for now).

Bug: b/203622329
Change-Id: I291623490695432376a05584c2638ced98ace494
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3436727
Reviewed-by: Mitsuru Oshima <[email protected]>
Reviewed-by: Yuwei Huang <[email protected]>
Reviewed-by: Nic Hollingum <[email protected]>
Commit-Queue: Chloe Pelling <[email protected]>
Cr-Commit-Position: refs/heads/main@{#968773}
diff --git a/components/exo/BUILD.gn b/components/exo/BUILD.gn
index c47239cd5..f3e3dd5 100644
--- a/components/exo/BUILD.gn
+++ b/components/exo/BUILD.gn
@@ -144,6 +144,7 @@
       "//ash/constants",
       "//ash/keyboard/ui",
       "//ash/public/cpp",
+      "//ash/resources/vector_icons",
       "//chromeos/crosapi/cpp",
       "//chromeos/ui/base",
       "//chromeos/ui/frame",
diff --git a/components/exo/ui_lock_controller.cc b/components/exo/ui_lock_controller.cc
index 9c1351b..785a022 100644
--- a/components/exo/ui_lock_controller.cc
+++ b/components/exo/ui_lock_controller.cc
@@ -8,6 +8,7 @@
 
 #include "ash/constants/app_types.h"
 #include "ash/constants/ash_features.h"
+#include "ash/resources/vector_icons/vector_icons.h"
 #include "ash/wm/window_state.h"
 #include "ash/wm/window_state_observer.h"
 #include "base/bind.h"
@@ -16,6 +17,7 @@
 #include "base/time/time.h"
 #include "base/timer/timer.h"
 #include "chromeos/ui/base/window_properties.h"
+#include "components/exo/pointer.h"
 #include "components/exo/seat.h"
 #include "components/exo/shell_surface_util.h"
 #include "components/exo/surface.h"
@@ -29,6 +31,7 @@
 #include "ui/base/l10n/l10n_util.h"
 #include "ui/events/event_constants.h"
 #include "ui/events/keycodes/dom/dom_code.h"
+#include "ui/gfx/paint_vector_icon.h"
 #include "ui/strings/grit/ui_strings.h"
 #include "ui/views/widget/widget.h"
 
@@ -40,15 +43,14 @@
 //
 // The exit popup is a circle with an 'X' close icon which exits fullscreen when
 // the user clicks it.
-// * It is not shown on windows such as borealis with property
-//   kEscHoldExitFullscreenToMinimized.
+// * It is only shown on windows with property kEscHoldToExitFullscreen.
 // * It is displayed when the mouse moves to the top 3px of the screen.
 // * It will hide after a 3s timeout, or the user moves below 150px.
 // * After hiding, there is a cooldown where it will not display again until the
 //   mouse moves below 150px.
 
-// Duration to show the 'Press and hold Esc' notification.
-constexpr auto kEscNotificationDuration = base::Seconds(4);
+// Duration to show notifications.
+constexpr auto kNotificationDuration = base::Seconds(4);
 // Position of Esc notification from top of screen.
 const int kEscNotificationTopPx = 45;
 // Duration to show the exit 'X' popup.
@@ -58,17 +60,54 @@
 // Hide the exit popup if mouse is below this height.
 constexpr float kExitPopupHideHeight = 150.f;
 
+// Once the pointer capture notification has finished showing without
+// being interrupted, don't show it again until this long has passed.
+constexpr auto kPointerCaptureNotificationCooldown = base::Minutes(5);
+
 constexpr int kUILockControllerSeatObserverPriority = 1;
 static_assert(
     exo::Seat::IsValidObserverPriority(kUILockControllerSeatObserverPriority),
     "kUILockCOntrollerSeatObserverPriority is not in the valid range");
 
+bool IsUILockControllerEnabled(aura::Window* window) {
+  if (!window)
+    return false;
+
+  if (window->GetProperty(chromeos::kEscHoldToExitFullscreen) ||
+      window->GetProperty(chromeos::kUseOverviewToExitFullscreen) ||
+      window->GetProperty(chromeos::kUseOverviewToExitPointerLock)) {
+    return true;
+  }
+  return false;
+}
+
+// Creates the separator view between bubble views of modifiers and key.
+std::unique_ptr<views::View> CreateIconView(const gfx::VectorIcon& icon) {
+  constexpr int kIconSize = 28;
+
+  std::unique_ptr<views::ImageView> view = std::make_unique<views::ImageView>();
+  gfx::ImageSkia image = gfx::CreateVectorIcon(icon, SK_ColorWHITE);
+  view->SetImage(ui::ImageModel::FromImageSkia(image));
+  view->SetImageSize(gfx::Size(kIconSize, kIconSize));
+  return view;
+}
+
 // Create and position Esc notification.
-views::Widget* CreateEscNotification(aura::Window* parent) {
+views::Widget* CreateEscNotification(aura::Window* parent,
+                                     int message_id,
+                                     int key_message_id) {
   auto content_view = std::make_unique<SubtleNotificationView>();
-  std::u16string accelerator = l10n_util::GetStringUTF16(IDS_APP_ESC_KEY);
-  content_view->UpdateContent(l10n_util::GetStringFUTF16(
-      IDS_FULLSCREEN_HOLD_ESC_TO_EXIT_FULLSCREEN, accelerator));
+  if (key_message_id == IDS_APP_OVERVIEW_KEY) {
+    std::vector<std::unique_ptr<views::View>> icons;
+    icons.push_back(CreateIconView(ash::kKsvOverviewIcon));
+    content_view->UpdateContent(
+        l10n_util::GetStringFUTF16(message_id,
+                                   l10n_util::GetStringUTF16(key_message_id)),
+        std::move(icons));
+  } else {
+    content_view->UpdateContent(l10n_util::GetStringFUTF16(
+        message_id, l10n_util::GetStringUTF16(key_message_id)));
+  }
   gfx::Size size = content_view->GetPreferredSize();
   views::Widget* popup = SubtleNotificationView::CreatePopupWidget(
       parent, std::move(content_view));
@@ -81,42 +120,95 @@
   return popup;
 }
 
-// Exits fullscreen to either default or minimized.
+// Exits fullscreen to previous state.
 void ExitFullscreen(aura::Window* window) {
   ash::WindowState* window_state = ash::WindowState::Get(window);
-  if (window->GetProperty(chromeos::kEscHoldExitFullscreenToMinimized))
-    window_state->Minimize();
-  else
+  if (window_state->IsFullscreen())
     window_state->Restore();
 }
 
-// Shows 'Press and hold ESC to exit fullscreen' message, and exit popup.
-class EscHoldNotifier : public ui::EventHandler,
-                        public ash::WindowStateObserver {
+// Owns the widgets for messages prompting to exit fullscreen/mouselock, and
+// the exit popup. Owned as a window property.
+class ExitNotifier : public ui::EventHandler, public ash::WindowStateObserver {
  public:
-  explicit EscHoldNotifier(aura::Window* window) : window_(window) {
+  explicit ExitNotifier(aura::Window* window) : window_(window) {
     ash::WindowState* window_state = ash::WindowState::Get(window);
     window_state_observation_.Observe(window_state);
     if (window_state->IsFullscreen())
       OnFullscreen();
   }
 
-  EscHoldNotifier(const EscHoldNotifier&) = delete;
-  EscHoldNotifier& operator=(const EscHoldNotifier&) = delete;
+  ExitNotifier(const ExitNotifier&) = delete;
+  ExitNotifier& operator=(const ExitNotifier&) = delete;
 
-  ~EscHoldNotifier() override { CloseAll(); }
+  ~ExitNotifier() override {
+    OnExitFullscreen();
+    ClosePointerCaptureNotification();
+  }
 
-  views::Widget* esc_notification() { return esc_notification_; }
+  views::Widget* fullscreen_esc_notification() {
+    return fullscreen_esc_notification_;
+  }
+
+  views::Widget* pointer_capture_notification() {
+    return pointer_capture_notification_;
+  }
 
   FullscreenControlPopup* exit_popup() { return exit_popup_.get(); }
 
+  void MaybeShowPointerCaptureNotification() {
+    // Respect cooldown.
+    if (base::TimeTicks::Now() < next_pointer_notify_time_)
+      return;
+
+    want_pointer_capture_notification_ = true;
+
+    // Don't show in fullscreen; the fullscreen notification will show and is
+    // prioritized.
+    ash::WindowState* window_state = ash::WindowState::Get(window_);
+    if (window_state->IsFullscreen())
+      return;
+
+    if (pointer_capture_notification_) {
+      pointer_capture_notification_->CloseWithReason(
+          views::Widget::ClosedReason::kUnspecified);
+    }
+    pointer_capture_notification_ = CreateEscNotification(
+        window_, IDS_PRESS_TO_EXIT_MOUSELOCK, IDS_APP_OVERVIEW_KEY);
+    pointer_capture_notification_->Show();
+
+    // Close Esc notification after 4s.
+    pointer_capture_notify_timer_.Start(
+        FROM_HERE, kNotificationDuration,
+        base::BindOnce(&ExitNotifier::OnPointerCaptureNotifyTimerFinished,
+                       base::Unretained(this)));
+  }
+
+  void ClosePointerCaptureNotification() {
+    pointer_capture_notify_timer_.Stop();
+    if (pointer_capture_notification_) {
+      pointer_capture_notification_->CloseWithReason(
+          views::Widget::ClosedReason::kUnspecified);
+      pointer_capture_notification_ = nullptr;
+    }
+  }
+
  private:
+  void OnPointerCaptureNotifyTimerFinished() {
+    // Start the cooldown when the timer successfully elapses, to ensure the
+    // notification was shown for a sufficiently long time.
+    next_pointer_notify_time_ =
+        base::TimeTicks::Now() + kPointerCaptureNotificationCooldown;
+    ClosePointerCaptureNotification();
+    want_pointer_capture_notification_ = false;
+  }
+
   // Overridden from ui::EventHandler:
   void OnMouseEvent(ui::MouseEvent* event) override {
     gfx::PointF point = event->location_f();
     aura::Window::ConvertPointToTarget(
         static_cast<aura::Window*>(event->target()), window_, &point);
-    if (!esc_notification_ && !exit_popup_cooldown_ &&
+    if (!fullscreen_esc_notification_ && !exit_popup_cooldown_ &&
         window_ == exo::WMHelper::GetInstance()->GetActiveWindow() &&
         point.y() <= kExitPopupDisplayHeight) {
       // Show exit popup if mouse is above 3px, unless esc notification is
@@ -129,10 +221,10 @@
       views::Widget* widget =
           views::Widget::GetTopLevelWidgetForNativeView(window_);
       exit_popup_->Show(widget->GetClientAreaBoundsInScreen());
-      exit_popup_timer_.Start(FROM_HERE, kExitPopupDuration,
-                              base::BindOnce(&EscHoldNotifier::HideExitPopup,
-                                             base::Unretained(this),
-                                             /*animate=*/true));
+      exit_popup_timer_.Start(
+          FROM_HERE, kExitPopupDuration,
+          base::BindOnce(&ExitNotifier::HideExitPopup, base::Unretained(this),
+                         /*animate=*/true));
       exit_popup_cooldown_ = true;
     } else if (point.y() > kExitPopupHideHeight) {
       // Hide exit popup if mouse is below 150px, reset cooloff.
@@ -149,14 +241,14 @@
     if (window_state->IsFullscreen()) {
       OnFullscreen();
     } else {
-      CloseAll();
+      OnExitFullscreen();
     }
   }
 
   void OnFullscreen() {
     // Register ui::EventHandler to watch if mouse goes to top of screen.
     if (!is_handling_events_ &&
-        !window_->GetProperty(chromeos::kEscHoldExitFullscreenToMinimized)) {
+        window_->GetProperty(chromeos::kEscHoldToExitFullscreen)) {
       window_->AddPreTargetHandler(this);
       is_handling_events_ = true;
     }
@@ -165,32 +257,57 @@
     if (window_ != exo::WMHelper::GetInstance()->GetActiveWindow())
       return;
 
-    if (!esc_notification_)
-      esc_notification_ = CreateEscNotification(window_);
-    esc_notification_->Show();
+    // Fullscreen notifications override pointer capture notifications.
+    ClosePointerCaptureNotification();
+
+    if (!fullscreen_esc_notification_) {
+      int message_id = window_->GetProperty(chromeos::kEscHoldToExitFullscreen)
+                           ? IDS_FULLSCREEN_HOLD_ESC_TO_EXIT_FULLSCREEN
+                           : IDS_FULLSCREEN_PRESS_ESC_TO_EXIT_FULLSCREEN;
+      fullscreen_esc_notification_ = CreateEscNotification(
+          window_, message_id,
+          window_->GetProperty(chromeos::kUseOverviewToExitFullscreen)
+              ? IDS_APP_OVERVIEW_KEY
+              : IDS_APP_ESC_KEY);
+    }
+    fullscreen_esc_notification_->Show();
 
     // Close Esc notification after 4s.
-    esc_notification_timer_.Start(
-        FROM_HERE, kEscNotificationDuration,
-        base::BindOnce(&EscHoldNotifier::CloseEscNotification,
+    fullscreen_notify_timer_.Start(
+        FROM_HERE, kNotificationDuration,
+        base::BindOnce(&ExitNotifier::CloseFullscreenEscNotification,
                        base::Unretained(this)));
   }
 
-  void CloseAll() {
+  void OnExitFullscreen() {
     if (is_handling_events_) {
       window_->RemovePreTargetHandler(this);
       is_handling_events_ = false;
     }
-    CloseEscNotification();
+    CloseFullscreenEscNotification();
     HideExitPopup();
   }
 
-  void CloseEscNotification() {
-    if (!esc_notification_)
+  void CloseFullscreenEscNotification() {
+    if (!fullscreen_esc_notification_)
       return;
-    esc_notification_->CloseWithReason(
+    fullscreen_esc_notification_->CloseWithReason(
         views::Widget::ClosedReason::kUnspecified);
-    esc_notification_ = nullptr;
+    fullscreen_esc_notification_ = nullptr;
+
+    // If a pointer capture notification was previously requested and didn't
+    // show (or didn't complete its timer), show it now.
+    //
+    // This is to prevent the following scenario:
+    //   1. App goes fullscreen
+    //   2. App immediately requests pointer capture; no notification is shown,
+    //      since the fullscreen notification is already visible.
+    //   3. App immediately unfullscreens; the fullscreen notification closes.
+    //
+    // Without this check, the app would have gained pointer capture without
+    // any notification showing.
+    if (want_pointer_capture_notification_)
+      MaybeShowPointerCaptureNotification();
   }
 
   void HideExitPopup(bool animate = false) {
@@ -198,12 +315,16 @@
       exit_popup_->Hide(animate);
   }
 
-  aura::Window* window_;
-  views::Widget* esc_notification_ = nullptr;
+  aura::Window* const window_;
+  views::Widget* fullscreen_esc_notification_ = nullptr;
+  views::Widget* pointer_capture_notification_ = nullptr;
+  bool want_pointer_capture_notification_ = false;
   std::unique_ptr<FullscreenControlPopup> exit_popup_;
   bool is_handling_events_ = false;
   bool exit_popup_cooldown_ = false;
-  base::OneShotTimer esc_notification_timer_;
+  base::OneShotTimer fullscreen_notify_timer_;
+  base::OneShotTimer pointer_capture_notify_timer_;
+  base::TimeTicks next_pointer_notify_time_;
   base::OneShotTimer exit_popup_timer_;
   base::ScopedObservation<ash::WindowState, ash::WindowStateObserver>
       window_state_observation_{this};
@@ -211,15 +332,35 @@
 
 }  // namespace
 
-DEFINE_UI_CLASS_PROPERTY_TYPE(EscHoldNotifier*)
+DEFINE_UI_CLASS_PROPERTY_TYPE(ExitNotifier*)
 
 namespace exo {
 namespace {
-DEFINE_OWNED_UI_CLASS_PROPERTY_KEY(EscHoldNotifier,
-                                   kEscHoldNotifierKey,
-                                   nullptr)
+DEFINE_OWNED_UI_CLASS_PROPERTY_KEY(ExitNotifier, kExitNotifierKey, nullptr)
+
+ExitNotifier* GetExitNotifier(aura::Window* window, bool create) {
+  if (!base::FeatureList::IsEnabled(chromeos::features::kExoLockNotification))
+    return nullptr;
+
+  if (!window)
+    return nullptr;
+
+  aura::Window* toplevel = window->GetToplevelWindow();
+  if (!IsUILockControllerEnabled(toplevel))
+    return nullptr;
+
+  ExitNotifier* notifier = toplevel->GetProperty(kExitNotifierKey);
+  if (!notifier && create) {
+    // Object is owned as a window property.
+    notifier = toplevel->SetProperty(kExitNotifierKey,
+                                     std::make_unique<ExitNotifier>(toplevel));
+  }
+
+  return notifier;
 }
 
+}  // namespace
+
 constexpr auto kLongPressEscapeDuration = base::Seconds(2);
 constexpr auto kExcludedFlags = ui::EF_SHIFT_DOWN | ui::EF_CONTROL_DOWN |
                                 ui::EF_ALT_DOWN | ui::EF_COMMAND_DOWN |
@@ -263,29 +404,55 @@
     return;
 
   aura::Window* window = gained_focus->window()->GetToplevelWindow();
-  if (!window)
+  if (!IsUILockControllerEnabled(window))
     return;
 
-  // If the window does not have kEscHoldToExitFullscreen, or we are already
-  // tracking it, then ignore.
-  if (!window->GetProperty(chromeos::kEscHoldToExitFullscreen) ||
-      window->GetProperty(kEscHoldNotifierKey)) {
-    return;
-  }
-
   // Object is owned as a window property.
-  window->SetProperty(kEscHoldNotifierKey,
-                      std::make_unique<EscHoldNotifier>(window));
+  if (!window->GetProperty(kExitNotifierKey)) {
+    window->SetProperty(kExitNotifierKey,
+                        std::make_unique<ExitNotifier>(window));
+  }
+}
+
+void UILockController::OnPointerCaptureEnabled(Pointer* pointer,
+                                               aura::Window* window) {
+  aura::Window* toplevel = window ? window->GetToplevelWindow() : nullptr;
+  if (!toplevel ||
+      !toplevel->GetProperty(chromeos::kUseOverviewToExitPointerLock))
+    return;
+
+  captured_pointers_.insert(pointer);
+  ExitNotifier* notifier = GetExitNotifier(window, false);
+  if (notifier)
+    notifier->MaybeShowPointerCaptureNotification();
+}
+
+void UILockController::OnPointerCaptureDisabled(Pointer* pointer,
+                                                aura::Window* window) {
+  if (captured_pointers_.empty())
+    return;
+
+  captured_pointers_.erase(pointer);
+  if (captured_pointers_.empty()) {
+    ExitNotifier* notifier = GetExitNotifier(window, false);
+    if (notifier)
+      notifier->ClosePointerCaptureNotification();
+  }
+}
+
+views::Widget* UILockController::GetPointerCaptureNotificationForTesting(
+    aura::Window* window) {
+  return window->GetProperty(kExitNotifierKey)->pointer_capture_notification();
 }
 
 views::Widget* UILockController::GetEscNotificationForTesting(
     aura::Window* window) {
-  return window->GetProperty(kEscHoldNotifierKey)->esc_notification();
+  return window->GetProperty(kExitNotifierKey)->fullscreen_esc_notification();
 }
 
 FullscreenControlPopup* UILockController::GetExitPopupForTesting(
     aura::Window* window) {
-  return window->GetProperty(kEscHoldNotifierKey)->exit_popup();
+  return window->GetProperty(kExitNotifierKey)->exit_popup();
 }
 
 namespace {
diff --git a/components/exo/ui_lock_controller.h b/components/exo/ui_lock_controller.h
index c10ddd9d..4dd96bb8 100644
--- a/components/exo/ui_lock_controller.h
+++ b/components/exo/ui_lock_controller.h
@@ -6,6 +6,7 @@
 #define COMPONENTS_EXO_UI_LOCK_CONTROLLER_H_
 
 #include "ash/shell.h"
+#include "base/containers/flat_set.h"
 #include "base/timer/timer.h"
 #include "components/exo/seat_observer.h"
 #include "ui/events/event_handler.h"
@@ -14,18 +15,18 @@
 
 namespace exo {
 
+class Pointer;
 class Seat;
 
 extern const base::TimeDelta kLongPressEscapeDuration;
 
-// Listens for long presses on the Escape key, which breaks out of various
-// kinds of "locks" that a window may hold.
+// Helps users to break out of various kinds of "locks" that a window may hold
+// (fullscreen, pointer lock).
 //
-// TODO(cpelling): For now this is just non-immersive fullscreen. Eventually
-// this should also break pointer lock.
-//
-// The "long keypress" design is inspired by Chromium's Keyboard Lock feature
-// (see https://chromestatus.com/feature/5642959835889664).
+// In some cases this is achieved by pressing and holding Escape, similar to
+// Chromium's Keyboard Lock feature
+// (see https://chromestatus.com/feature/5642959835889664). In other cases we
+// nudge the user to use Overview.
 class UILockController : public ui::EventHandler, public SeatObserver {
  public:
   explicit UILockController(Seat* seat);
@@ -40,8 +41,13 @@
   void OnSurfaceFocused(Surface* gained_focus,
                         Surface* lost_focus,
                         bool has_focued_surface) override;
+  void OnPointerCaptureEnabled(Pointer* pointer,
+                               aura::Window* capture_window) override;
+  void OnPointerCaptureDisabled(Pointer* pointer,
+                                aura::Window* capture_window) override;
 
   views::Widget* GetEscNotificationForTesting(aura::Window* window);
+  views::Widget* GetPointerCaptureNotificationForTesting(aura::Window* window);
   FullscreenControlPopup* GetExitPopupForTesting(aura::Window* window);
 
  private:
@@ -57,6 +63,9 @@
   // dangle if the Surface is destroyed while the timer is running. Valid only
   // for comparison purposes.
   Surface* focused_surface_to_unlock_ = nullptr;
+
+  // Pointers currently being captured.
+  base::flat_set<base::raw_ptr<Pointer>> captured_pointers_;
 };
 
 }  // namespace exo
diff --git a/components/exo/ui_lock_controller_unittest.cc b/components/exo/ui_lock_controller_unittest.cc
index d2002e2..6f8b1d9 100644
--- a/components/exo/ui_lock_controller_unittest.cc
+++ b/components/exo/ui_lock_controller_unittest.cc
@@ -12,12 +12,16 @@
 #include "chromeos/ui/base/window_properties.h"
 #include "components/exo/buffer.h"
 #include "components/exo/display.h"
+#include "components/exo/pointer.h"
+#include "components/exo/pointer_constraint_delegate.h"
+#include "components/exo/pointer_delegate.h"
 #include "components/exo/shell_surface.h"
 #include "components/exo/surface.h"
 #include "components/exo/test/exo_test_base.h"
 #include "components/exo/test/exo_test_helper.h"
 #include "components/exo/wm_helper.h"
 #include "components/fullscreen_control/fullscreen_control_popup.h"
+#include "testing/gmock/include/gmock/gmock.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "ui/base/class_property.h"
 #include "ui/gfx/animation/animation_test_api.h"
@@ -28,7 +32,7 @@
 namespace {
 
 constexpr char kNoEscHoldAppId[] = "no-esc-hold";
-constexpr char kEscToMinimizeAppId[] = "esc-to-minimize";
+constexpr char kOverviewToExitAppId[] = "overview-to-exit";
 
 struct SurfaceTriplet {
   std::unique_ptr<Surface> surface;
@@ -60,6 +64,58 @@
   }
 };
 
+class MockPointerDelegate : public PointerDelegate {
+ public:
+  MockPointerDelegate(Surface* surface) {
+    EXPECT_CALL(*this, CanAcceptPointerEventsForSurface(surface))
+        .WillRepeatedly(testing::Return(true));
+  }
+
+  // Overridden from PointerDelegate:
+  MOCK_METHOD1(OnPointerDestroying, void(Pointer*));
+  MOCK_CONST_METHOD1(CanAcceptPointerEventsForSurface, bool(Surface*));
+  MOCK_METHOD3(OnPointerEnter, void(Surface*, const gfx::PointF&, int));
+  MOCK_METHOD1(OnPointerLeave, void(Surface*));
+  MOCK_METHOD2(OnPointerMotion, void(base::TimeTicks, const gfx::PointF&));
+  MOCK_METHOD3(OnPointerButton, void(base::TimeTicks, int, bool));
+  MOCK_METHOD3(OnPointerScroll,
+               void(base::TimeTicks, const gfx::Vector2dF&, bool));
+  MOCK_METHOD1(OnPointerScrollStop, void(base::TimeTicks));
+  MOCK_METHOD0(OnPointerFrame, void());
+};
+
+class MockPointerConstraintDelegate : public PointerConstraintDelegate {
+ public:
+  MockPointerConstraintDelegate(Pointer* pointer, Surface* surface)
+      : pointer_(pointer) {
+    EXPECT_CALL(*this, GetConstrainedSurface())
+        .WillRepeatedly(testing::Return(surface));
+    ON_CALL(*this, OnConstraintActivated).WillByDefault([this]() {
+      activated_count++;
+    });
+    ON_CALL(*this, OnConstraintBroken).WillByDefault([this]() {
+      broken_count++;
+    });
+  }
+
+  ~MockPointerConstraintDelegate() {
+    // Notifying destruction here removes some boilerplate from tests.
+    pointer_->OnPointerConstraintDelegateDestroying(this);
+  }
+
+  // Overridden from PointerConstraintDelegate:
+  MOCK_METHOD0(OnConstraintActivated, void());
+  MOCK_METHOD0(OnAlreadyConstrained, void());
+  MOCK_METHOD0(OnConstraintBroken, void());
+  MOCK_METHOD0(IsPersistent, bool());
+  MOCK_METHOD0(GetConstrainedSurface, Surface*());
+  MOCK_METHOD0(OnDefunct, void());
+
+  raw_ptr<Pointer> pointer_;
+  int activated_count = 0;
+  int broken_count = 0;
+};
+
 class UILockControllerTest : public test::ExoTestBase {
  public:
   UILockControllerTest()
@@ -77,11 +133,16 @@
     void PopulateProperties(
         const Params& params,
         ui::PropertyHandler& out_properties_container) override {
-      out_properties_container.SetProperty(chromeos::kEscHoldToExitFullscreen,
-                                           params.app_id != kNoEscHoldAppId);
       out_properties_container.SetProperty(
-          chromeos::kEscHoldExitFullscreenToMinimized,
-          params.app_id == kEscToMinimizeAppId);
+          chromeos::kEscHoldToExitFullscreen,
+          params.app_id != kNoEscHoldAppId &&
+              params.app_id != kOverviewToExitAppId);
+      out_properties_container.SetProperty(
+          chromeos::kUseOverviewToExitFullscreen,
+          params.app_id == kOverviewToExitAppId);
+      out_properties_container.SetProperty(
+          chromeos::kUseOverviewToExitPointerLock,
+          params.app_id == kOverviewToExitAppId);
     }
   };
 
@@ -89,8 +150,10 @@
   void SetUp() override {
     test::ExoTestBase::SetUp();
     seat_ = std::make_unique<Seat>();
-    scoped_feature_list_.InitAndEnableFeature(
-        chromeos::features::kExoLockNotification);
+    scoped_feature_list_.InitWithFeatures(
+        {chromeos::features::kExoLockNotification,
+         chromeos::features::kExoPointerLock},
+        {});
     WMHelper::GetInstance()->RegisterAppPropertyResolver(
         std::make_unique<TestPropertyResolver>());
   }
@@ -121,6 +184,11 @@
         surface->GetTopLevelWindow());
   }
 
+  views::Widget* GetPointerCaptureNotification(SurfaceTriplet* surface) {
+    return seat_->GetUILockControllerForTesting()
+        ->GetPointerCaptureNotificationForTesting(surface->GetTopLevelWindow());
+  }
+
   bool IsExitPopupVisible(aura::Window* window) {
     FullscreenControlPopup* popup =
         seat_->GetUILockControllerForTesting()->GetExitPopupForTesting(window);
@@ -262,39 +330,6 @@
   EXPECT_TRUE(window_state->IsFullscreen());
 }
 
-TEST_F(UILockControllerTest, HoldingEscapeMinimizesIfPropertySet) {
-  SurfaceTriplet test_surface = BuildSurface(1024, 768);
-  // Set chromeos::kEscHoldExitFullscreenToMinimized on TopLevelWindow.
-  test_surface.shell_surface->SetApplicationId(kEscToMinimizeAppId);
-  test_surface.shell_surface->SetUseImmersiveForFullscreen(false);
-  test_surface.shell_surface->SetFullscreen(true);
-  test_surface.surface->Commit();
-  auto* window_state = test_surface.GetTopLevelWindowState();
-  EXPECT_TRUE(window_state->IsFullscreen());
-
-  GetEventGenerator()->PressKey(ui::VKEY_ESCAPE, ui::EF_NONE);
-  task_environment()->FastForwardBy(base::Seconds(1));
-  EXPECT_TRUE(window_state->IsFullscreen());  // no change yet
-
-  task_environment()->FastForwardBy(base::Seconds(1));
-  EXPECT_FALSE(window_state->IsFullscreen());
-  EXPECT_TRUE(window_state->IsMinimized());
-}
-
-TEST_F(UILockControllerTest, HoldingEscapeDoesNotMinimizeIfWindowed) {
-  SurfaceTriplet test_surface = BuildSurface(1024, 768);
-  // Set chromeos::kEscHoldExitFullscreenToMinimized on TopLevelWindow.
-  test_surface.shell_surface->SetApplicationId(kEscToMinimizeAppId);
-  test_surface.shell_surface->SetUseImmersiveForFullscreen(false);
-  test_surface.surface->Commit();
-  auto* window_state = test_surface.GetTopLevelWindowState();
-
-  GetEventGenerator()->PressKey(ui::VKEY_ESCAPE, ui::EF_NONE);
-  task_environment()->FastForwardBy(base::Seconds(2));
-
-  EXPECT_FALSE(window_state->IsMinimized());
-}
-
 TEST_F(UILockControllerTest, FullScreenShowsEscNotification) {
   SurfaceTriplet test_surface = BuildSurface(1024, 768);
   test_surface.shell_surface->SetUseImmersiveForFullscreen(false);
@@ -395,6 +430,85 @@
       esc_notification->GetWindowBoundsInScreen()));
 }
 
+TEST_F(UILockControllerTest, PointerLockShowsNotification) {
+  SurfaceTriplet test_surface = BuildSurface(1024, 768);
+  test_surface.shell_surface->SetApplicationId(kOverviewToExitAppId);
+  test_surface.surface->Commit();
+  testing::NiceMock<MockPointerDelegate> delegate(test_surface.surface.get());
+  Pointer pointer(&delegate, seat_.get());
+  testing::NiceMock<MockPointerConstraintDelegate> constraint(
+      &pointer, test_surface.surface.get());
+  EXPECT_FALSE(GetPointerCaptureNotification(&test_surface));
+
+  EXPECT_TRUE(pointer.ConstrainPointer(&constraint));
+
+  EXPECT_TRUE(GetPointerCaptureNotification(&test_surface));
+}
+
+TEST_F(UILockControllerTest, PointerLockNotificationObeysCooldown) {
+  // Arrange: Set up a pointer capture notification.
+  SurfaceTriplet test_surface = BuildSurface(1024, 768);
+  test_surface.shell_surface->SetApplicationId(kOverviewToExitAppId);
+  test_surface.surface->Commit();
+  testing::NiceMock<MockPointerDelegate> delegate(test_surface.surface.get());
+  Pointer pointer(&delegate, seat_.get());
+  testing::NiceMock<MockPointerConstraintDelegate> constraint(
+      &pointer, test_surface.surface.get());
+  EXPECT_TRUE(pointer.ConstrainPointer(&constraint));
+  EXPECT_TRUE(GetPointerCaptureNotification(&test_surface));
+
+  // Act: Wait for the notification to timeout.
+  task_environment()->FastForwardBy(base::Seconds(5));
+
+  // Assert: Notification has disappeared.
+  EXPECT_FALSE(GetPointerCaptureNotification(&test_surface));
+
+  // Act: Remove and re-apply the constraint.
+  pointer.OnPointerConstraintDelegateDestroying(&constraint);
+  EXPECT_TRUE(pointer.ConstrainPointer(&constraint));
+
+  // Assert: Notification not shown due to the cooldown.
+  EXPECT_FALSE(GetPointerCaptureNotification(&test_surface));
+
+  // Act: Wait for the cooldown, then re-apply again
+  pointer.OnPointerConstraintDelegateDestroying(&constraint);
+  task_environment()->FastForwardBy(base::Minutes(5));
+  EXPECT_TRUE(pointer.ConstrainPointer(&constraint));
+
+  // Assert: Cooldown has expired so notification is shown.
+  EXPECT_TRUE(GetPointerCaptureNotification(&test_surface));
+}
+
+TEST_F(UILockControllerTest, FullscreenNotificationHasPriority) {
+  // Arrange: Set up a pointer capture notification.
+  SurfaceTriplet test_surface = BuildSurface(1024, 768);
+  test_surface.shell_surface->SetApplicationId(kOverviewToExitAppId);
+  test_surface.surface->Commit();
+  testing::NiceMock<MockPointerDelegate> delegate(test_surface.surface.get());
+  Pointer pointer(&delegate, seat_.get());
+  testing::NiceMock<MockPointerConstraintDelegate> constraint(
+      &pointer, test_surface.surface.get());
+  EXPECT_TRUE(pointer.ConstrainPointer(&constraint));
+  EXPECT_TRUE(GetPointerCaptureNotification(&test_surface));
+
+  // Act: Go fullscreen.
+  test_surface.shell_surface->SetUseImmersiveForFullscreen(false);
+  test_surface.shell_surface->SetFullscreen(true);
+  test_surface.surface->Commit();
+
+  // Assert: Fullscreen notification overrides pointer notification.
+  EXPECT_FALSE(GetPointerCaptureNotification(&test_surface));
+  EXPECT_TRUE(GetEscNotification(&test_surface));
+
+  // Act: Exit fullscreen.
+  test_surface.shell_surface->SetFullscreen(false);
+  test_surface.surface->Commit();
+
+  // Assert: Pointer notification returns, since it was interrupted.
+  EXPECT_TRUE(GetPointerCaptureNotification(&test_surface));
+  EXPECT_FALSE(GetEscNotification(&test_surface));
+}
+
 TEST_F(UILockControllerTest, ExitPopup) {
   SurfaceTriplet test_surface = BuildSurface(1024, 768);
   test_surface.shell_surface->SetUseImmersiveForFullscreen(false);
@@ -449,10 +563,10 @@
   EXPECT_FALSE(IsExitPopupVisible(window));
 }
 
-TEST_F(UILockControllerTest, ExitPopupNotShownIfPropertySet) {
+TEST_F(UILockControllerTest, ExitPopupNotShownForOverviewCase) {
   SurfaceTriplet test_surface = BuildSurface(1024, 768);
-  // Set chromeos::kEscHoldExitFullscreenToMinimized on TopLevelWindow.
-  test_surface.shell_surface->SetApplicationId(kEscToMinimizeAppId);
+  // Set chromeos::kUseOverviewToExitFullscreen on TopLevelWindow.
+  test_surface.shell_surface->SetApplicationId(kOverviewToExitAppId);
   test_surface.shell_surface->SetUseImmersiveForFullscreen(false);
   test_surface.shell_surface->SetFullscreen(true);
   test_surface.surface->Commit();
diff --git a/components/fullscreen_control/subtle_notification_view.cc b/components/fullscreen_control/subtle_notification_view.cc
index 9f2e552c..f1a87bf2 100644
--- a/components/fullscreen_control/subtle_notification_view.cc
+++ b/components/fullscreen_control/subtle_notification_view.cc
@@ -41,6 +41,9 @@
 const int kKeyNameCornerRadius = 2;
 const int kKeyNamePaddingPx = 5;
 
+// Spacing between the key name and image, if any.
+const int kKeyNameImageSpacingPx = 3;
+
 // The context used to obtain typography for the instruction text. It's not
 // really a dialog, but a dialog title is a good fit.
 constexpr int kInstructionTextContext = views::style::CONTEXT_DIALOG_TITLE;
@@ -65,12 +68,16 @@
 
   std::u16string GetText() const;
   void SetText(const std::u16string& text);
+  void SetTextAndImages(const std::u16string& text,
+                        std::vector<std::unique_ptr<views::View>> key_images);
 
  private:
   // Adds a label to the end of the notification text. If |format_as_key|,
   // surrounds the label in a rounded-rect border to indicate that it is a
   // keyboard key.
-  void AddTextSegment(const std::u16string& text, bool format_as_key);
+  void AddTextSegment(const std::u16string& text,
+                      bool format_as_key,
+                      std::unique_ptr<views::View> key_image);
 
   std::u16string text_;
 };
@@ -91,8 +98,14 @@
 
 void SubtleNotificationView::InstructionView::SetText(
     const std::u16string& text) {
+  SetTextAndImages(text, std::vector<std::unique_ptr<views::View>>());
+}
+
+void SubtleNotificationView::InstructionView::SetTextAndImages(
+    const std::u16string& text,
+    std::vector<std::unique_ptr<views::View>> key_images) {
   // Avoid replacing the contents with the same text.
-  if (text == text_)
+  if (text == text_ && key_images.empty())
     return;
 
   RemoveAllChildViews();
@@ -106,11 +119,26 @@
   // list is also empty (rather than containing a single empty string).
   DCHECK(segments.empty() || segments.size() % 2 == 1);
 
+  // Every second segment is formatted as a key, so should have an image
+  // specified for it (even if that image is an empty unique_ptr).
+  // Or, we can have no key images at all.
+  //
+  // There should always be one non-key segment preceding each key segment
+  // (even if empty, as above) and one segment at the end, so the total number
+  // of segments should be double the number of key segments, plus one.
+  DCHECK(key_images.empty() || key_images.size() * 2 + 1 == segments.size());
+
   // Add text segment, alternating between non-key (no border) and key (border)
   // formatting.
   bool format_as_key = false;
+  int idx = 0;
   for (const auto& segment : segments) {
-    AddTextSegment(segment, format_as_key);
+    std::unique_ptr<views::View> key_image;
+    if (!key_images.empty() && format_as_key) {
+      key_image = std::move(key_images[idx]);
+      idx++;
+    }
+    AddTextSegment(segment, format_as_key, std::move(key_image));
     format_as_key = !format_as_key;
   }
 
@@ -119,7 +147,8 @@
 
 void SubtleNotificationView::InstructionView::AddTextSegment(
     const std::u16string& text,
-    bool format_as_key) {
+    bool format_as_key,
+    std::unique_ptr<views::View> key_image) {
   constexpr SkColor kForegroundColor = SK_ColorWHITE;
 
   views::Label* label = new views::Label(text, kInstructionTextContext);
@@ -127,6 +156,7 @@
   label->SetBackgroundColor(kSubtleNotificationBackgroundColor);
 
   if (!format_as_key) {
+    DCHECK(!key_image);
     AddChildView(label);
     return;
   }
@@ -134,10 +164,12 @@
   views::View* key = new views::View;
   auto key_name_layout = std::make_unique<views::BoxLayout>(
       views::BoxLayout::Orientation::kHorizontal,
-      gfx::Insets(0, kKeyNamePaddingPx), 0);
+      gfx::Insets(0, kKeyNamePaddingPx), kKeyNameImageSpacingPx);
   key_name_layout->set_minimum_cross_axis_size(
       label->GetPreferredSize().height() + kKeyNamePaddingPx * 2);
   key->SetLayoutManager(std::move(key_name_layout));
+  if (key_image)
+    key->AddChildView(std::move(key_image));
   key->AddChildView(label);
   // The key name has a border around it.
   std::unique_ptr<views::Border> border(views::CreateRoundedRectBorder(
@@ -177,6 +209,14 @@
   Layout();
 }
 
+void SubtleNotificationView::UpdateContent(
+    const std::u16string& instruction_text,
+    std::vector<std::unique_ptr<views::View>> key_images) {
+  instruction_view_->SetTextAndImages(instruction_text, std::move(key_images));
+  instruction_view_->SetVisible(!instruction_text.empty());
+  Layout();
+}
+
 // static
 views::Widget* SubtleNotificationView::CreatePopupWidget(
     gfx::NativeView parent_view,
diff --git a/components/fullscreen_control/subtle_notification_view.h b/components/fullscreen_control/subtle_notification_view.h
index 700820c..5fe1808 100644
--- a/components/fullscreen_control/subtle_notification_view.h
+++ b/components/fullscreen_control/subtle_notification_view.h
@@ -7,10 +7,12 @@
 
 #include <memory>
 #include <string>
+#include <vector>
 
 #include "base/memory/raw_ptr.h"
 #include "ui/base/metadata/metadata_header_macros.h"
 #include "ui/gfx/native_widget_types.h"
+#include "ui/views/controls/image_view.h"
 #include "ui/views/view.h"
 
 namespace views {
@@ -35,6 +37,14 @@
   // empty hide the view.
   void UpdateContent(const std::u16string& instruction_text);
 
+  // Display the |instruction_text| to the user, with the |key_images| inside
+  // the rectangles that represent keys. |key_images| must either be empty, or
+  // the same length as the number of text segments inside pipe characters.
+  //
+  // If |instruction_text| is empty hide the view.
+  void UpdateContent(const std::u16string& instruction_text,
+                     std::vector<std::unique_ptr<views::View>> key_images);
+
   // Creates a Widget containing a SubtleNotificationView.
   static views::Widget* CreatePopupWidget(
       gfx::NativeView parent_view,
diff --git a/components/fullscreen_control_strings.grdp b/components/fullscreen_control_strings.grdp
index 269e155..ddc0cb14 100644
--- a/components/fullscreen_control_strings.grdp
+++ b/components/fullscreen_control_strings.grdp
@@ -9,4 +9,7 @@
   <message name="IDS_FULLSCREEN_PRESS_ESC_TO_EXIT_FULLSCREEN" desc="Text displayed in the bubble to tell users how to return from fullscreen mode (where the web page occupies the entire screen) to normal mode. Please surround the name of the key (e.g. 'Esc') in pipe characters so it can be rendered as a key.">
     Press |<ph name="ACCELERATOR">$1<ex>Esc</ex></ph>| to exit full screen
   </message>
+  <message name="IDS_PRESS_TO_EXIT_MOUSELOCK" desc="Text displayed in the bubble to tell users how to escape from mouselock mode (where the mouse cursor is hidden) by activating Chrome OS's Overview mode. Please surround the name of the key (e.g. 'Overview') in pipe characters so it can be rendered as a key.">
+    Press |<ph name="ACCELERATOR">$1<ex>Overview</ex></ph>| to show your cursor
+  </message>
 </grit-part>
diff --git a/components/fullscreen_control_strings_grdp/IDS_PRESS_TO_EXIT_MOUSELOCK.png.sha1 b/components/fullscreen_control_strings_grdp/IDS_PRESS_TO_EXIT_MOUSELOCK.png.sha1
new file mode 100644
index 0000000..5c4eaae
--- /dev/null
+++ b/components/fullscreen_control_strings_grdp/IDS_PRESS_TO_EXIT_MOUSELOCK.png.sha1
@@ -0,0 +1 @@
+6dcd489062a9bdba9d982b7034a95aee2b4083ef
\ No newline at end of file