chromium/ash/wm/window_cycle/window_cycle_controller.cc

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/wm/window_cycle/window_cycle_controller.h"

#include "ash/accessibility/accessibility_controller.h"
#include "ash/constants/ash_pref_names.h"
#include "ash/events/event_rewriter_controller_impl.h"
#include "ash/metrics/task_switch_metrics_recorder.h"
#include "ash/metrics/task_switch_source.h"
#include "ash/metrics/user_metrics_recorder.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/wallpaper/wallpaper_controller_impl.h"
#include "ash/wm/desks/desk.h"
#include "ash/wm/desks/desk_bar_controller.h"
#include "ash/wm/desks/desks_util.h"
#include "ash/wm/mru_window_tracker.h"
#include "ash/wm/overview/overview_controller.h"
#include "ash/wm/screen_pinning_controller.h"
#include "ash/wm/snap_group/snap_group.h"
#include "ash/wm/snap_group/snap_group_controller.h"
#include "ash/wm/window_cycle/window_cycle_event_filter.h"
#include "ash/wm/window_cycle/window_cycle_list.h"
#include "ash/wm/window_util.h"
#include "base/check.h"
#include "base/containers/contains.h"
#include "base/functional/bind.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/user_metrics.h"
#include "components/prefs/pref_service.h"
#include "ui/base/l10n/l10n_util.h"

namespace ash {

namespace {

constexpr char kAltTabDesksSwitchDistanceHistogramName[] =
    "Ash.WindowCycleController.DesksSwitchDistance";
constexpr char kAltTabInitialModeHistogramName[] =
    "Ash.WindowCycleController.InitialMode";
constexpr char kAltTabItemsHistogramName[] = "Ash.WindowCycleController.Items";
constexpr char kAltTabSwitchModeHistogramName[] =
    "Ash.WindowCycleController.SwitchMode";
constexpr char kAltTabModeSwitchSourceHistogramName[] =
    "Ash.WindowCycleController.ModeSwitchSource";
constexpr char kSameAppWindowCycleIsSameAppHistogramName[] =
    "Ash.WindowCycleController.SameApp.IsSameApp";
constexpr char kSameAppWindowCycleDeskModeHistogramName[] =
    "Ash.WindowCycleController.SameApp.DeskMode";

// Enumeration of the alt-tab modes to record initial mode and mode switch.
// Note that these values are persisted to histograms so existing values should
// remain unchanged and new values should be added to the end.
enum class AltTabMode {
  // The window list includes all windows from all desks.
  kAllDesks,
  // The window list only includes windows from the active desk.
  kCurrentDesk,
  kMaxValue = kCurrentDesk,
};

// Returns the most recently active window from the |window_list| or nullptr
// if the list is empty.
aura::Window* GetActiveWindow(
    const WindowCycleController::WindowList& window_list) {
  return window_list.empty() ? nullptr : window_list[0];
}

void ReportPossibleDesksSwitchStats(int active_desk_container_id_before_cycle) {
  // Report only for users who have 2 or more desks, since we're only interested
  // in seeing how users of Virtual Desks use window cycling.
  auto* desks_controller = DesksController::Get();
  if (!desks_controller)
    return;

  if (desks_controller->desks().size() < 2)
    return;

  // Note that this functions is called while a potential desk switch animation
  // is starting, in this case we want the target active desk (i.e. the soon-to-
  // be active desk after the animation finishes).
  const int active_desk_container_id_after_cycle =
      desks_controller->GetTargetActiveDesk()->container_id();
  DCHECK_NE(active_desk_container_id_before_cycle, kShellWindowId_Invalid);
  DCHECK_NE(active_desk_container_id_after_cycle, kShellWindowId_Invalid);

  // Note that the desks containers IDs are consecutive. See
  // |ash::ShellWindowId|.
  const int desks_switch_distance =
      std::abs(active_desk_container_id_after_cycle -
               active_desk_container_id_before_cycle);
  base::UmaHistogramExactLinear(kAltTabDesksSwitchDistanceHistogramName,
                                desks_switch_distance,
                                desks_util::kDesksUpperLimit);
}

}  // namespace

//////////////////////////////////////////////////////////////////////////////
// WindowCycleController, public:

WindowCycleController::WindowCycleController() {
  Shell::Get()->session_controller()->AddObserver(this);
}

WindowCycleController::~WindowCycleController() {
  Shell::Get()->session_controller()->RemoveObserver(this);
}

// static
bool WindowCycleController::CanCycle() {
  return !Shell::Get()->session_controller()->IsScreenLocked() &&
         !Shell::IsSystemModalWindowOpen() &&
         !Shell::Get()->screen_pinning_controller()->IsPinned() &&
         !window_util::IsAnyWindowDragged() &&
         !Shell::Get()->desks_controller()->AreDesksBeingModified();
}

// static
void WindowCycleController::RegisterProfilePrefs(PrefRegistrySimple* registry) {
  registry->RegisterBooleanPref(prefs::kAltTabPerDesk, DesksMruType::kAllDesks);
}

void WindowCycleController::HandleCycleWindow(WindowCyclingDirection direction,
                                              bool same_app_only) {
  if (!CanCycle())
    return;

  const bool should_start_alt_tab = !IsCycling();
  if (should_start_alt_tab)
    StartCycling(same_app_only);

  Step(direction, /*starting_alt_tab_or_switching_mode=*/should_start_alt_tab);
}

void WindowCycleController::HandleKeyboardNavigation(
    KeyboardNavDirection direction) {
  // If the UI is not shown yet, discard the event.
  if (!CanCycle() || !IsCycling() || !window_cycle_list_->cycle_view() ||
      !IsValidKeyboardNavigation(direction)) {
    return;
  }

  switch (direction) {
    // Pressing the Up arrow key moves the focus from the window cycle list
    // to the tab slider button.
    case KeyboardNavDirection::kUp:
      DCHECK(!IsTabSliderFocused() && IsInteractiveAltTabModeAllowed());
      window_cycle_list_->SetFocusTabSlider(true);
      // Focusing the alt-tab mode button announces the current mode.
      Shell::Get()
          ->accessibility_controller()
          ->TriggerAccessibilityAlertWithMessage(l10n_util::GetStringUTF8(
              IsAltTabPerActiveDesk()
                  ? IDS_ASH_ALT_TAB_CURRENT_DESK_MODE_SELECTED_TITLE
                  : IDS_ASH_ALT_TAB_ALL_DESKS_MODE_SELECTED_TITLE));
      break;
    // Pressing the Down arrow key does the opposite of the Up arrow key.
    case KeyboardNavDirection::kDown: {
      DCHECK(IsTabSliderFocused());
      window_cycle_list_->SetFocusTabSlider(false);
      aura::Window* target_window = window_cycle_list_->GetTargetWindow();
      // Cannot press the Down arrow key if there is no window.
      DCHECK(target_window);
      // Announce the selected window in the window cycle list.
      Shell::Get()
          ->accessibility_controller()
          ->TriggerAccessibilityAlertWithMessage(
              l10n_util::GetStringFUTF8(IDS_ASH_ALT_TAB_WINDOW_SELECTED_TITLE,
                                        target_window->GetTitle()));
      break;
    }
    // Pressing the Left or Right arrow keys cycles through the window list
    // or switches alt-tab mode depending on which component is focused.
    case KeyboardNavDirection::kRight:
    case KeyboardNavDirection::kLeft:
      if (!IsTabSliderFocused()) {
        // Cycling through the window list if focusing the window.
        HandleCycleWindow(direction == KeyboardNavDirection::kRight
                              ? WindowCyclingDirection::kForward
                              : WindowCyclingDirection::kBackward);
      } else {
        // Switch the mode if focusing the button. Navigating right triggers
        // the right button corresponding to the active desk mode. On the other
        // hand, navigating left enables the all-desk mode.
        OnModeChanged(direction == KeyboardNavDirection::kRight,
                      ModeSwitchSource::kKeyboard);
      }
      break;
    case KeyboardNavDirection::kInvalid:
    default:
      NOTREACHED();
  }
}

void WindowCycleController::Drag(float delta_x) {
  DCHECK(window_cycle_list_);
  window_cycle_list_->Drag(delta_x);
}

void WindowCycleController::StartFling(float velocity_x) {
  DCHECK(window_cycle_list_);
  window_cycle_list_->StartFling(velocity_x);
}

void WindowCycleController::StartCycling(bool same_app_only) {
  Shell* shell = Shell::Get();

  // Close the wallpaper preview if it is open to prevent visual glitches where
  // the window view item for the preview is transparent
  // (http://crbug.com/895265).
  shell->wallpaper_controller()->MaybeClosePreviewWallpaper();
  shell->event_rewriter_controller()->SetAltDownRemappingEnabled(false);

  // End overview as the window cycle list takes over window switching.
  shell->overview_controller()->EndOverview(
      OverviewEndAction::kStartedWindowCycle);

  // Close all desk bars as the window cycle list takes over window switching.
  if (auto* desk_bar_controller =
          shell->desks_controller()->desk_bar_controller()) {
    desk_bar_controller->CloseAllDeskBars();
  }

  WindowCycleController::WindowList window_list = CreateWindowList();
  SaveCurrentActiveDeskAndWindow(window_list);
  window_cycle_list_ =
      std::make_unique<WindowCycleList>(window_list, same_app_only);
  event_filter_ = std::make_unique<WindowCycleEventFilter>();
  base::UmaHistogramBoolean(kSameAppWindowCycleIsSameAppHistogramName,
                            same_app_only);
  if (!same_app_only) {
    base::RecordAction(base::UserMetricsAction("WindowCycleController_Cycle"));
    base::UmaHistogramCounts100(kAltTabItemsHistogramName, window_list.size());
    if (IsInteractiveAltTabModeAllowed()) {
      // When alt-tab interactive mode is available, report the initial alt-tab
      // mode which indicates the user's preferred mode.
      base::UmaHistogramEnumeration(kAltTabInitialModeHistogramName,
                                    IsAltTabPerActiveDesk()
                                        ? AltTabMode::kCurrentDesk
                                        : AltTabMode::kAllDesks);
    }
  }

  desks_observation_.Observe(DesksController::Get());
}

void WindowCycleController::CompleteCycling() {
  DCHECK(window_cycle_list_);
  window_cycle_list_->set_user_did_accept(true);
  StopCycling();
}

void WindowCycleController::CancelCycling() {
  StopCycling();
}

void WindowCycleController::MaybeResetCycleList() {
  if (!IsCycling())
    return;

  WindowCycleController::WindowList window_list = CreateWindowList();
  SaveCurrentActiveDeskAndWindow(window_list);

  DCHECK(window_cycle_list_);
  window_cycle_list_->ReplaceWindows(window_list);
}

void WindowCycleController::SetFocusedWindow(aura::Window* window) {
  if (!IsCycling())
    return;

  DCHECK(window_cycle_list_);
  window_cycle_list_->SetFocusedWindow(window);
}

bool WindowCycleController::IsEventInCycleView(
    const ui::LocatedEvent* event) const {
  return window_cycle_list_ && window_cycle_list_->IsEventInCycleView(event);
}

aura::Window* WindowCycleController::GetWindowAtPoint(
    const ui::LocatedEvent* event) {
  return window_cycle_list_ ? window_cycle_list_->GetWindowAtPoint(event)
                            : nullptr;
}

bool WindowCycleController::IsEventInTabSliderContainer(
    const ui::LocatedEvent* event) const {
  return window_cycle_list_ &&
         window_cycle_list_->IsEventInTabSliderContainer(event);
}

bool WindowCycleController::IsWindowListVisible() const {
  return window_cycle_list_ && window_cycle_list_->ShouldShowUi();
}

bool WindowCycleController::IsInteractiveAltTabModeAllowed() const {
  return Shell::Get()->desks_controller()->GetNumberOfDesks() > 1;
}

bool WindowCycleController::IsAltTabPerActiveDesk() const {
  return IsInteractiveAltTabModeAllowed() && active_user_pref_service_ &&
         active_user_pref_service_->GetBoolean(prefs::kAltTabPerDesk);
}

bool WindowCycleController::IsSwitchingMode() const {
  return IsInteractiveAltTabModeAllowed() && is_switching_mode_;
}

bool WindowCycleController::IsTabSliderFocused() const {
  return IsInteractiveAltTabModeAllowed() &&
         window_cycle_list_->IsTabSliderFocused();
}

void WindowCycleController::OnModeChanged(bool per_desk,
                                          ModeSwitchSource source) {
  DCHECK(IsInteractiveAltTabModeAllowed() && IsCycling());
  // Save to the active user prefs.
  auto* prefs = Shell::Get()->session_controller()->GetActivePrefService();
  if (!prefs) {
    // Can be null in tests.
    return;
  }
  // Avoid an unnecessary update if any.
  if (per_desk == prefs->GetBoolean(prefs::kAltTabPerDesk))
    return;
  prefs->SetBoolean(prefs::kAltTabPerDesk, per_desk);

  // Report the alt-tab mode the user switches to and the source of switch.
  if (!window_cycle_list_->same_app_only()) {
    base::UmaHistogramEnumeration(
        kAltTabSwitchModeHistogramName,
        per_desk ? AltTabMode::kCurrentDesk : AltTabMode::kAllDesks);
    base::UmaHistogramEnumeration(kAltTabModeSwitchSourceHistogramName, source);
  }

  // Announce the new mode and the updated window selection via ChromeVox.
  aura::Window* target_window = window_cycle_list_->GetTargetWindow();
  const std::string mode_switched_string = l10n_util::GetStringUTF8(
      per_desk ? IDS_ASH_ALT_TAB_CURRENT_DESK_MODE_SELECTED_TITLE
               : IDS_ASH_ALT_TAB_ALL_DESKS_MODE_SELECTED_TITLE);
  // A ChromeVox string announcing the selected window in the window cycle list
  // or no recent items if there's no window in the list.
  const std::string window_selected_string =
      target_window
          ? l10n_util::GetStringFUTF8(IDS_ASH_ALT_TAB_WINDOW_SELECTED_TITLE,
                                      target_window->GetTitle())
          : l10n_util::GetStringUTF8(IDS_ASH_OVERVIEW_NO_RECENT_ITEMS);
  switch (source) {
    case ModeSwitchSource::kClick:
      Shell::Get()
          ->accessibility_controller()
          ->TriggerAccessibilityAlertWithMessage(base::JoinString(
              {mode_switched_string, window_selected_string}, " "));
      // If the user clicks the mode button, remove the focus from it.
      window_cycle_list_->SetFocusTabSlider(false);
      break;
    case ModeSwitchSource::kKeyboard:
      // Additionally, during keyboard navigation, notify that the user can
      // press the Down arrow key to navigate among the cycle windows if the
      // list is not empty.
      Shell::Get()
          ->accessibility_controller()
          ->TriggerAccessibilityAlertWithMessage(base::JoinString(
              {mode_switched_string, window_selected_string,
               target_window ? l10n_util::GetStringUTF8(
                                   IDS_ASH_ALT_TAB_FOCUS_WINDOW_LIST_TITLE)
                             : std::string()},
              " "));
      break;
    default:
      NOTREACHED();
  }
}

void WindowCycleController::OnActiveUserPrefServiceChanged(
    PrefService* pref_service) {
  active_user_pref_service_ = pref_service;
  InitFromUserPrefs();
}

void WindowCycleController::OnDeskAdded(const Desk* desk, bool from_undo) {
  CancelCycling();
}

void WindowCycleController::OnDeskRemoved(const Desk* desk) {
  CancelCycling();
}

//////////////////////////////////////////////////////////////////////////////
// WindowCycleController, private:

WindowCycleController::WindowList WindowCycleController::CreateWindowList() {
  WindowList window_list = BuildWindowListForWindowCycling(
      IsAltTabPerActiveDesk() ? kActiveDesk : kAllDesks);

  // Window cycle list windows will handle showing their transient related
  // windows, so if a window in |window_list| has a transient root also in
  // |window_list|, we can remove it as the transient root will handle showing
  // the window.
  window_util::EnsureTransientRoots(&window_list);
  return window_list;
}

MruWindowTracker::WindowList
WindowCycleController::BuildWindowListForWindowCycling(
    DesksMruType desks_mru_type) {
  const auto window_list =
      Shell::Get()->mru_window_tracker()->BuildWindowForCycleWithPipList(
          desks_mru_type);

  SnapGroupController* snap_group_controller = SnapGroupController::Get();
  if (!snap_group_controller) {
    return window_list;
  }

  MruWindowTracker::WindowList adjusted_window_list;
  for (aura::Window* window : window_list) {
    // The latter-activated window in a snap group should have been added. Skip
    // inserting to avoid duplicates.
    if (base::Contains(adjusted_window_list, window)) {
      continue;
    }

    if (SnapGroup* snap_group =
            snap_group_controller->GetSnapGroupForGivenWindow(window)) {
      // Insert the windows if they belong to a group following the order of the
      // actual window layout, i.e. primary snapped window comes first followed
      // by the secondary snapped window.
      adjusted_window_list.push_back(
          snap_group->GetPhysicallyLeftOrTopWindow());
      adjusted_window_list.push_back(
          snap_group->GetPhysicallyRightOrBottomWindow());
    } else {
      adjusted_window_list.push_back(window);
    }
  }

  return adjusted_window_list;
}

void WindowCycleController::SaveCurrentActiveDeskAndWindow(
    const WindowCycleController::WindowList& window_list) {
  active_desk_container_id_before_cycle_ =
      desks_util::GetActiveDeskContainerId();
  active_window_before_window_cycle_ = GetActiveWindow(window_list);
}

void WindowCycleController::Step(WindowCyclingDirection direction,
                                 bool starting_alt_tab_or_switching_mode) {
  DCHECK(window_cycle_list_);
  window_cycle_list_->Step(direction, starting_alt_tab_or_switching_mode);
}

void WindowCycleController::StopCycling() {
  // There's an edge case where `StopCycling()` is already triggered via an alt
  // release event, but user doesn't release the tap on the
  // `window_cycle_list_`. If we reset `window_cycle_list_` first,
  // `WindowEventDispatcher::DispatchSyntheticTouchEvent` will be triggered
  // because of the availability changed for the `window_cycle_list_`. Thus
  // `event_filter_` will still receive the event and try to handle the event
  // even though it's in the process of stopping cycling. To avoid this, we
  // should remove our event filter first. Please check
  // https://crbug.com/1228381 for more details.
  event_filter_.reset();

  desks_observation_.Reset();
  const bool was_same_app_only = window_cycle_list_->same_app_only();
  window_cycle_list_.reset();

  // We can't use the MRU window list here to get the active window, since
  // cycling can activate a window on a different desk, leading to a desk-switch
  // animation launching. Getting the MRU window list for the active desk now
  // will always be for the current active desk, not the target active desk.
  aura::Window* active_window_after_window_cycle =
      window_util::GetActiveWindow();

  if (was_same_app_only) {
    base::UmaHistogramEnumeration(kSameAppWindowCycleDeskModeHistogramName,
                                  IsAltTabPerActiveDesk()
                                      ? AltTabMode::kCurrentDesk
                                      : AltTabMode::kAllDesks);
  } else if (active_window_after_window_cycle != nullptr &&
             active_window_before_window_cycle_ !=
                 active_window_after_window_cycle) {
    Shell::Get()->metrics()->task_switch_metrics_recorder().OnTaskSwitch(
        TaskSwitchSource::WINDOW_CYCLE_CONTROLLER);

    ReportPossibleDesksSwitchStats(active_desk_container_id_before_cycle_);
  }

  active_window_before_window_cycle_ = nullptr;
  active_desk_container_id_before_cycle_ = kShellWindowId_Invalid;
  Shell::Get()->event_rewriter_controller()->SetAltDownRemappingEnabled(true);
}

void WindowCycleController::InitFromUserPrefs() {
  DCHECK(active_user_pref_service_);

  pref_change_registrar_ = std::make_unique<PrefChangeRegistrar>();
  pref_change_registrar_->Init(active_user_pref_service_);
  pref_change_registrar_->Add(
      prefs::kAltTabPerDesk,
      base::BindRepeating(&WindowCycleController::OnAltTabModePrefChanged,
                          base::Unretained(this)));

  OnAltTabModePrefChanged();
}

void WindowCycleController::OnAltTabModePrefChanged() {
  // Only update UI for alt-tab mode if the user is using alt-tab with the
  // interactive alt-tab mode supported.
  if (!IsInteractiveAltTabModeAllowed() || !IsCycling())
    return;

  is_switching_mode_ = true;

  // Update the window cycle list.
  MaybeResetCycleList();

  // After the cycle is reset, imitate the same forward cycling behavior as
  // starting alt-tab with `Step()`, which makes sure the correct window is
  // selected and focused.
  Step(WindowCyclingDirection::kForward,
       /*starting_alt_tab_or_switching_mode=*/true);

  // Update tab slider button UI.
  window_cycle_list_->OnModePrefsChanged();

  is_switching_mode_ = false;
}

bool WindowCycleController::IsValidKeyboardNavigation(
    KeyboardNavDirection direction) const {
  // Only allow Left and Right arrow keys if interactive alt-tab mode is not
  // in use.
  if (!IsInteractiveAltTabModeAllowed()) {
    return direction == KeyboardNavDirection::kLeft ||
           direction == KeyboardNavDirection::kRight;
  }

  // If the focus is on the window cycle list, the user can navigate up to
  // focus the mode buttons, or left and right to change the window selection.
  if (!IsTabSliderFocused())
    return direction != KeyboardNavDirection::kDown;

  // If the focus is on the tab slider button, the user can navigate down to
  // focus the non-empty list, determined by non-null target window. The user
  // can only navigate left while focusing the right button and vice versa.
  const bool per_desk = IsAltTabPerActiveDesk();
  return (direction == KeyboardNavDirection::kDown &&
          window_cycle_list_->GetTargetWindow()) ||
         (per_desk && direction == KeyboardNavDirection::kLeft) ||
         (!per_desk && direction == KeyboardNavDirection::kRight);
}

}  // namespace ash