chromium/ash/wm/snap_group/snap_group_controller.cc

// Copyright 2023 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/snap_group/snap_group_controller.h"

#include <optional>
#include <utility>
#include <vector>

#include "ash/root_window_controller.h"
#include "ash/shell.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/overview/overview_utils.h"
#include "ash/wm/snap_group/snap_group.h"
#include "ash/wm/snap_group/snap_group_constants.h"
#include "ash/wm/snap_group/snap_group_metrics.h"
#include "ash/wm/snap_group/snap_group_observer.h"
#include "ash/wm/splitview/layout_divider_controller.h"
#include "ash/wm/splitview/split_view_constants.h"
#include "ash/wm/splitview/split_view_controller.h"
#include "ash/wm/splitview/split_view_utils.h"
#include "ash/wm/tablet_mode/tablet_mode_controller.h"
#include "ash/wm/window_state.h"
#include "ash/wm/window_util.h"
#include "ash/wm/wm_metrics.h"
#include "base/check.h"
#include "base/check_op.h"
#include "base/containers/contains.h"
#include "base/containers/unique_ptr_adapters.h"
#include "base/metrics/user_metrics.h"
#include "base/numerics/ranges.h"
#include "base/time/time.h"
#include "chromeos/ui/base/window_state_type.h"
#include "ui/display/screen.h"
#include "ui/display/tablet_state.h"

namespace ash {

namespace {

SnapGroupController* g_instance = nullptr;

// Returns true if both of the windows in `snap_group` are visible.
// TODO(b/333772909): Precautionary check for group minimize. See if we still
// need this after group minimize is removed.
bool AreSnapGroupWindowsVisible(const SnapGroup* snap_group) {
  return snap_group->window1()->IsVisible() &&
         snap_group->window2()->IsVisible();
}

}  // namespace

SnapGroupController::SnapGroupController() {
  OverviewController::Get()->AddObserver(this);
  CHECK_EQ(g_instance, nullptr);
  g_instance = this;
}

SnapGroupController::~SnapGroupController() {
  OverviewController::Get()->RemoveObserver(this);
  CHECK_EQ(g_instance, this);
  g_instance = nullptr;
}

// static
SnapGroupController* SnapGroupController::Get() {
  // TODO(michelefan): Add CHECK(g_instance) after the snap group controller
  // feature is enabled by default.
  return g_instance;
}

bool SnapGroupController::AreWindowsInSnapGroup(aura::Window* window1,
                                                aura::Window* window2) const {
  DCHECK(window1);
  DCHECK(window2);
  return window1 == RetrieveTheOtherWindowInSnapGroup(window2) &&
         window2 == RetrieveTheOtherWindowInSnapGroup(window1);
}

bool SnapGroupController::OnWindowSnapped(
    aura::Window* window,
    WindowSnapActionSource snap_action_source) {
  // Only consider valid snap action sources or the window is being
  // auto-snapped or transitioned from tablet mode.
  const bool can_group_or_replace =
      CanSnapActionSourceStartFasterSplitView(snap_action_source) ||
      snap_action_source ==
          WindowSnapActionSource::kSnapByClamshellTabletTransition ||
      snap_action_source == WindowSnapActionSource::kAutoSnapInSplitView;
  if (!can_group_or_replace) {
    return false;
  }

  // If there is no opposite snapped window, we are done.
  aura::Window* opposite = GetOppositeVisibleSnappedWindow(window);
  if (!opposite) {
    return false;
  }

  // Disallow adding to group if the snap ratio gap exceeds the allowed
  // threshold.
  if (!IsSnapRatioGapWithinThreshold(window, opposite)) {
    base::RecordAction(base::UserMetricsAction("SnapGroups_SnapDirect"));
    return false;
  }

  // First attempt snap to replace. Snap groups in overview would be excluded by
  // `GetOppositeVisibleSnappedWindow()`.
  if (MaybeSnapToReplace(window, opposite, snap_action_source)) {
    return true;
  }

  if (auto* snap_group =
          AddSnapGroup(window, opposite, /*replace=*/false,
                       /*carry_over_creation_time=*/std::nullopt)) {
    aura::Window* target_window = nullptr;
    switch (snap_action_source) {
      case WindowSnapActionSource::kSnapByWindowLayoutMenu:
      case WindowSnapActionSource::kLacrosSnapButtonOrWindowLayoutMenu:
      case WindowSnapActionSource::kSnapByClamshellTabletTransition:
        // If the window was snapped via the layout menu, respect its
        // requested snap ratio. We also refresh the bounds for tablet
        // transition to ensure no divider gap.
        target_window = window;
        break;
      case WindowSnapActionSource::kDragWindowToEdgeToSnap:
      case WindowSnapActionSource::kLongPressCaptionButtonToSnap:
      case WindowSnapActionSource::kDragOrSelectOverviewWindowToSnap:
      case WindowSnapActionSource::kAutoSnapInSplitView:
        // Else if using a drag to snap or auto-snap action source, respect the
        // opposite window's snap ratio. This is to give the impression of
        // filling the layout and feels more intuitive to the user.
        target_window = opposite;
        break;
      default:
        // Else no need to refresh the group bounds.
        return true;
    }

    const float snap_ratio = window_util::GetSnapRatioForWindow(target_window);
    // Apply the target window's snap ratio *after* the group creation so we
    // can actually enforce it.
    snap_group->ApplyPrimarySnapRatio(
        IsPhysicallyLeftOrTop(target_window) ? snap_ratio : 1.f - snap_ratio);
    return true;
  }

  return false;
}

SnapGroup* SnapGroupController::AddSnapGroup(
    aura::Window* window1,
    aura::Window* window2,
    bool replace,
    std::optional<base::TimeTicks> carry_over_creation_time) {
  // The windows may already be in a snap group, if for example a snap group is
  // formed, then a window is re-snapped via the window layout menu.
  if (AreWindowsInSnapGroup(window1, window2)) {
    return GetSnapGroupForGivenWindow(window1);
  }

  // We should only allow snap group to be created for windows that have the
  // same parent.
  if (window1->parent() != window2->parent()) {
    return nullptr;
  }

  // Disallow snap group creation for unresizable windows.
  if (!WindowState::Get(window1)->CanResize() ||
      !WindowState::Get(window2)->CanResize()) {
    return nullptr;
  }

  // We only allow snap group to be created if the windows fit the work area.
  if (!CanWindowsFitInWorkArea(window1, window2)) {
    return nullptr;
  }

  // Disallow forming a Snap Group if either of the windows is configured to be
  // "visible on all workspaces".
  if (desks_util::IsWindowVisibleOnAllWorkspaces(window1) ||
      desks_util::IsWindowVisibleOnAllWorkspaces(window2)) {
    return nullptr;
  }

  if (base::Contains(window_to_snap_group_map_, window1) ||
      base::Contains(window_to_snap_group_map_, window2)) {
    return nullptr;
  }

  std::unique_ptr<SnapGroup> snap_group =
      std::make_unique<SnapGroup>(window1, window2, carry_over_creation_time);

  window_to_snap_group_map_.emplace(window1, snap_group.get());
  window_to_snap_group_map_.emplace(window2, snap_group.get());

  // Bounds have to be refreshed after snap group is created together with
  // divider and added to `window_to_snap_group_map_`. Otherwise, the snap ratio
  // will not be precisely calculated see `GetCurrentSnapRatio()` in
  // window_state.cc.
  auto* snap_group_ptr = snap_group.get();

  snap_groups_.push_back(std::move(snap_group));

  for (auto& observer : observers_) {
    observer.OnSnapGroupAdded(snap_group_ptr);
  }

  if (!replace) {
    ReportSnapGroupsCountHistogram(/*count=*/snap_groups_.size());
    base::RecordAction(base::UserMetricsAction("SnapGroups_AddSnapGroup"));
  }

  return snap_group_ptr;
}

bool SnapGroupController::RemoveSnapGroup(SnapGroup* snap_group,
                                          SnapGroupExitPoint exit_point) {
  CHECK(snap_group);

  const bool snap_to_replace = exit_point == SnapGroupExitPoint::kSnapToReplace;
  if (!snap_to_replace) {
    // Records persistence duration and Snap Groups count when the removal of
    // `group_to_remove` is not due to 'Snap to Replace', as this is considered
    // an extension of the snap group's lifespan.
    RecordSnapGroupPersistenceDuration(base::TimeTicks::Now() -
                                       snap_group->carry_over_creation_time_);
  }

  // We should always record the actual duration of the Snap Group upon removal.
  RecordSnapGroupActualDuration(base::TimeTicks::Now() -
                                snap_group->actual_creation_time_);

  aura::Window* window1 = snap_group->window1();
  aura::Window* window2 = snap_group->window2();

  CHECK_EQ(window_to_snap_group_map_.erase(window1), 1u);
  CHECK_EQ(window_to_snap_group_map_.erase(window2), 1u);

  auto iter =
      base::ranges::find_if(snap_groups_, base::MatchesUniquePtr(snap_group));
  DCHECK(iter != snap_groups_.end());

  for (auto& observer : observers_) {
    observer.OnSnapGroupRemoving(snap_group, exit_point);
  }

  auto group_to_remove = std::move(*iter);
  snap_groups_.erase(iter);
  group_to_remove->Shutdown();
  base::SequencedTaskRunner::GetCurrentDefault()->DeleteSoon(
      FROM_HERE, std::move(group_to_remove));

  if (!snap_to_replace) {
    ReportSnapGroupsCountHistogram(/*count=*/snap_groups_.size());
    base::RecordAction(base::UserMetricsAction("SnapGroups_RemoveSnapGroup"));
  }

  RecordSnapGroupExitPoint(exit_point);

  return true;
}

bool SnapGroupController::RemoveSnapGroupContainingWindow(
    aura::Window* window,
    SnapGroupExitPoint exit_point) {
  SnapGroup* snap_group = GetSnapGroupForGivenWindow(window);
  if (snap_group == nullptr) {
    return false;
  }

  return RemoveSnapGroup(snap_group, exit_point);
}

SnapGroup* SnapGroupController::GetSnapGroupForGivenWindow(
    const aura::Window* window) const {
  if (!window) {
    return nullptr;
  }
  auto iter = window_to_snap_group_map_.find(window);
  return iter != window_to_snap_group_map_.end() ? iter->second : nullptr;
}

SnapGroup* SnapGroupController::GetTopmostVisibleSnapGroup(
    const aura::Window* target_root) const {
  for (const aura::Window* top_window : GetActiveDeskAppWindowsInZOrder(
           const_cast<aura::Window*>(target_root))) {
    // Skip to the topmost window on `target_root`, ignoring occlusion-exempt
    // windows.
    if (ShouldExcludeForOcclusionCheck(top_window, target_root)) {
      continue;
    }
    // Note that if `top_window` is floated or pip'ed, it would not belong to a
    // snap group.
    if (auto* snap_group = GetSnapGroupForGivenWindow(top_window);
        snap_group && AreSnapGroupWindowsVisible(snap_group)) {
      return snap_group;
    }
    // Else if `top_window` does not belong to a snap group, we are done.
    break;
  }
  return nullptr;
}

SnapGroup* SnapGroupController::GetTopmostSnapGroup() const {
  // Use `BuildMruWindowList()` to include all windows on the active desk across
  // all root windows.
  for (const aura::Window* window :
       Shell::Get()->mru_window_tracker()->BuildMruWindowList(kActiveDesk)) {
    if (auto* snap_group = GetSnapGroupForGivenWindow(window);
        snap_group && AreSnapGroupWindowsVisible(snap_group)) {
      return snap_group;
    }
  }
  return nullptr;
}

std::optional<std::pair<aura::Window*, aura::Window*>>
SnapGroupController::GetWindowPairForSnapToReplaceWithKeyboardShortcut() {
  // Snap-to-replace targets only partially obscured Snap Group, which is the
  // topmost Snap Group.
  SnapGroup* top_snap_group = GetTopmostSnapGroup();
  if (!top_snap_group) {
    return std::nullopt;
  }

  aura::Window* root_window = window_util::GetRootWindowAt(
      display::Screen::GetScreen()->GetCursorScreenPoint());
  aura::Window::Windows windows = GetActiveDeskAppWindowsInZOrder(root_window);
  for (size_t i = 0; i < windows.size(); i++) {
    aura::Window* window = windows[i];
    const auto* window_state = WindowState::Get(window);
    if (!window->IsVisible() || window_state->IsMinimized() ||
        desks_util::IsWindowVisibleOnAllWorkspaces(window)) {
      continue;
    }

    // If the `window` being traversed belongs to a Snap Group and is the first
    // window encountered in the list, we can immediately exit the loop. Since
    // the other window in the group will also be on top, indicating the group
    // is not partially obscured (a condition we need for snap-to-replace).
    if (SnapGroup* snap_group_being_traversed =
            GetSnapGroupForGivenWindow(window);
        snap_group_being_traversed && i == 0 &&
        window == snap_group_being_traversed->GetTopMostWindowInGroup()) {
      break;
    }

    // Snap-to-Replace Eligibility Check:
    //   - Upon finding a snapped window, assess its potential for
    //   snap-to-replace.
    //   - This entails checking against the `top_snap_group`.
    //   - The combined snap ratios of the snapped window and the opposite
    //   window within the `top_snap_group` must equal one. This signifies that
    //   the two windows would perfectly fill the workspace if snapped together.
    //  - If this eligibility check passes:
    //     i. The snapped window is confirmed as a valid candidate for
    //     snap-to-replace.
    //     ii. The opposite snapped window within the `top_snap_group` is
    //     identified as another member of the `window_pair` required to form
    //     the new Snap Group after the snap-to-replace.
    const auto window_state_type = window_state->GetStateType();
    const auto snap_ratio = window_state->snap_ratio();
    aura::Window* visible_snapped_window_in_snap_group = nullptr;
    if (window_state_type == chromeos::WindowStateType::kPrimarySnapped) {
      CHECK(snap_ratio);
      visible_snapped_window_in_snap_group = top_snap_group->window2();
      if (base::IsApproximatelyEqual(
              *WindowState::Get(visible_snapped_window_in_snap_group)
                      ->snap_ratio() +
                  *snap_ratio,
              1.f, std::numeric_limits<float>::epsilon())) {
        return std::make_pair(window, visible_snapped_window_in_snap_group);
      }
    }

    if (window_state_type == chromeos::WindowStateType::kSecondarySnapped) {
      CHECK(snap_ratio);
      visible_snapped_window_in_snap_group = top_snap_group->window1();
      if (base::IsApproximatelyEqual(
              *WindowState::Get(visible_snapped_window_in_snap_group)
                      ->snap_ratio() +
                  *snap_ratio,
              1.f, std::numeric_limits<float>::epsilon())) {
        return std::make_pair(visible_snapped_window_in_snap_group, window);
      }
    }
  }

  return std::nullopt;
}

void SnapGroupController::AddObserver(SnapGroupObserver* observer) {
  observers_.AddObserver(observer);
}

void SnapGroupController::RemoveObserver(SnapGroupObserver* observer) {
  observers_.RemoveObserver(observer);
}

void SnapGroupController::OnFloatUnfloatCompleted(aura::Window* window) {
  // Needed because float -> snap will trigger a workspace event, during which
  // we want to ignore bounds events since the window is unfloating. Only when
  // all the nested unfloat events triggered by the original snap event is
  // finished and `WindowState::is_handling_float_event_` is reset can we
  // refresh the group and send bounds events.
  if (auto* snap_group = GetSnapGroupForGivenWindow(window)) {
    snap_group->RefreshSnapGroup();
  }
}

void SnapGroupController::OnOverviewModeStarting() {
  if (display::Screen::GetScreen()->InTabletMode()) {
    return;
  }

  for (const auto& snap_group : snap_groups_) {
    snap_group->HideDivider();
  }
}

void SnapGroupController::OnOverviewModeEnding(
    OverviewSession* overview_session) {
  if (display::Screen::GetScreen()->InTabletMode()) {
    return;
  }

  // On Overview mode ending, call `RefreshSnapGroup()` to refresh the bounds
  // of the snapped windows and divider. This ensures they either maintain a
  // proper fit within the work area or are gracefully broken from the group
  // if they no longer fit due to potential device scale factor in Overview.
  // By doing this refresh after exiting Overview, we prevent heavy visual
  // updates and re-layout (break `OverviewGroupItem` back to two individual
  // `Overviewitem`s) while in Overview mode.
  for (const auto& snap_group : snap_groups_) {
    snap_group->RefreshSnapGroup();
  }
}

void SnapGroupController::OnOverviewModeEndingAnimationComplete(bool canceled) {
  if (display::Screen::GetScreen()->InTabletMode()) {
    return;
  }

  for (const auto& snap_group : snap_groups_) {
    snap_group->ShowDivider();
  }
}

void SnapGroupController::OnDisplayTabletStateChanged(
    display::TabletState state) {
  switch (state) {
    case display::TabletState::kInClamshellMode:
    case display::TabletState::kEnteringTabletMode:
      break;
    case display::TabletState::kInTabletMode:
      OnTabletModeStarted();
      break;
    case display::TabletState::kExitingTabletMode:
      // TODO(b/327269057): Consider moving split view transition here.
      // Currently it's handled by `MaybeEndSplitViewAndOverview()` in
      // `TabletModeWindowManager`.
      RestoreSnapGroups();
      break;
  }
}

bool SnapGroupController::MaybeSnapToReplace(
    aura::Window* to_be_snapped_window,
    aura::Window* opposite_snapped_window,
    WindowSnapActionSource snap_action_source) {
  // Early return when
  // 1. In tablet mode;
  // 2. `to_be_snapped_window` belongs to a snap group, this can happen when
  // moving a snap group to another desk with snap groups.
  if (display::Screen::GetScreen()->InTabletMode() ||
      GetSnapGroupForGivenWindow(to_be_snapped_window)) {
    return false;
  }

  // TODO(b/331305840): Come up with an API to retrieve the snapped window on
  // the same side as the `to_be_snapped_window` to simplify the logic.
  SnapGroup* group_to_replace =
      GetSnapGroupForGivenWindow(opposite_snapped_window);
  if (!group_to_replace) {
    return false;
  }

  WindowState* window_state = WindowState::Get(to_be_snapped_window);
  const auto window_state_type = window_state->GetStateType();

  aura::Window* curr_primary_window = group_to_replace->window1();
  aura::Window* curr_secondary_window = group_to_replace->window2();
  aura::Window* new_primary_window = nullptr;
  aura::Window* new_secondary_window = nullptr;
  aura::Window* to_be_replaced_window = nullptr;
  if (window_state_type == chromeos::WindowStateType::kPrimarySnapped) {
    to_be_replaced_window = curr_primary_window;
    new_primary_window = to_be_snapped_window;
    new_secondary_window = curr_secondary_window;
  } else {
    CHECK_EQ(window_state_type, chromeos::WindowStateType::kSecondarySnapped);

    to_be_replaced_window = curr_secondary_window;
    new_primary_window = curr_primary_window;
    new_secondary_window = to_be_snapped_window;
  }

  // Disallow snap-to-replace if `to_be_replaced_window` is on a different
  // parent container with the `to_be_snapped_window`.
  if (to_be_replaced_window->parent() != to_be_snapped_window->parent()) {
    return false;
  }

  // If the new windows can't fit, do not allow snap to replace.
  if (!CanWindowsFitInWorkArea(new_primary_window, new_secondary_window)) {
    return false;
  }

  // TODO(b/331470570): Consider directly replacing the `to_be_snapped_window`
  // within the `snap_group`.
  const auto carry_over_creation_time =
      group_to_replace->carry_over_creation_time_;
  RemoveSnapGroup(group_to_replace, SnapGroupExitPoint::kSnapToReplace);
  SnapGroup* new_snap_group = AddSnapGroup(
      new_primary_window, new_secondary_window, /*replace=*/true,
      /*carry_over_creation_time=*/
      std::make_optional<base::TimeTicks>(carry_over_creation_time));

  // Snap Group may not be formed successfully.
  if (!new_snap_group) {
    return false;
  }

  base::RecordAction(base::UserMetricsAction("SnapGroups_SnapToReplace"));

  // Apply the `primary_window_snap_ratio` to the `new_snap_group` such that the
  // snap ratio of the `group_to_replace` is preserved.
  const float snapped_window_snap_ratio =
      window_util::GetSnapRatioForWindow(to_be_replaced_window);
  const float primary_window_snap_ratio =
      new_primary_window == to_be_snapped_window
          ? snapped_window_snap_ratio
          : 1 - snapped_window_snap_ratio;
  new_snap_group->ApplyPrimarySnapRatio(primary_window_snap_ratio);
  return true;
}

aura::Window* SnapGroupController::RetrieveTheOtherWindowInSnapGroup(
    aura::Window* window) const {
  if (window_to_snap_group_map_.find(window) ==
      window_to_snap_group_map_.end()) {
    return nullptr;
  }

  SnapGroup* snap_group = window_to_snap_group_map_.find(window)->second;
  return window == snap_group->window1() ? snap_group->window2()
                                         : snap_group->window1();
}

void SnapGroupController::RestoreSnapGroups() {
  // TODO(b/288335850): Currently `SplitViewController` only supports two
  // windows, the group at the end will overwrite any split view operations.
  // This will be addressed in multiple snap groups feature.
  // TODO(b/288334530): Iterate through all the displays and restore the snap
  // groups based on the mru order.
  for (const auto& snap_group : snap_groups_) {
    RestoreSnapState(snap_group.get());
  }
}

void SnapGroupController::RestoreSnapState(SnapGroup* snap_group) {
  CHECK(snap_group);

  auto* window1 = snap_group->window1();
  const auto window1_snap_ratio = WindowState::Get(window1)->snap_ratio();
  CHECK(window1_snap_ratio);

  auto* window2 = snap_group->window2();
  const auto window2_snap_ratio = WindowState::Get(window2)->snap_ratio();
  CHECK(window2_snap_ratio);

  // Preferably to use `SplitViewController::SnapWindow()` as it also handles
  // asynchronous operations from client controlled state.
  SplitViewController* split_view_controller =
      SplitViewController::Get(window1->GetRootWindow());
  split_view_controller->SnapWindow(
      window1, SnapPosition::kPrimary,
      WindowSnapActionSource::kSnapByWindowStateRestore, *window1_snap_ratio);
  split_view_controller->SnapWindow(
      window2, SnapPosition::kSecondary,
      WindowSnapActionSource::kSnapByWindowStateRestore, *window2_snap_ratio);
}

void SnapGroupController::OnTabletModeStarted() {
  // TODO(b/327269057): Define tablet <-> clamshell transition.
  while (!snap_groups_.empty()) {
    RemoveSnapGroup(snap_groups_.back().get(),
                    SnapGroupExitPoint::kTabletTransition);
  }
}

}  // namespace ash