chromium/ash/wm/splitview/auto_snap_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/splitview/auto_snap_controller.h"

#include <optional>

#include "ash/root_window_controller.h"
#include "ash/shell.h"
#include "ash/wm/desks/desks_controller.h"
#include "ash/wm/mru_window_tracker.h"
#include "ash/wm/overview/overview_controller.h"
#include "ash/wm/overview/overview_metrics.h"
#include "ash/wm/overview/overview_session.h"
#include "ash/wm/snap_group/snap_group_controller.h"
#include "ash/wm/splitview/split_view_controller.h"
#include "ash/wm/splitview/split_view_overview_session.h"
#include "ash/wm/splitview/split_view_types.h"
#include "ash/wm/splitview/split_view_utils.h"
#include "ash/wm/window_properties.h"
#include "ash/wm/window_util.h"
#include "ui/wm/public/activation_client.h"

namespace ash {

namespace {

// Returns the snap ratio for the given `window` to be auto-snapped on the
// opposite position of the screen.
std::optional<float> CalculateAutoSnapRatio(
    SplitViewController* split_view_controller,
    aura::Window* window) {
  if (Shell::Get()->IsInTabletMode()) {
    return split_view_controller->ComputeAutoSnapRatio(window);
  }

  auto* window_state = WindowState::Get(window);
  if (auto* split_view_overview_session =
          RootWindowController::ForWindow(window)
              ->split_view_overview_session();
      split_view_overview_session &&
      split_view_overview_session->window() != window) {
    if (!window_state->CanSnap()) {
      return std::nullopt;
    }

    const float snap_ratio =
        1.f - WindowState::Get(split_view_overview_session->window())
                  ->snap_ratio()
                  .value_or(chromeos::kDefaultSnapRatio);
    return std::make_optional<float>(snap_ratio);
  }

  return window_state->snap_ratio();
}

}  // namespace

AutoSnapController::AutoSnapController(aura::Window* root_window)
    : root_window_(root_window) {
  Shell::Get()->activation_client()->AddObserver(this);

  AddWindow(root_window);
  for (aura::Window* window :
       Shell::Get()->mru_window_tracker()->BuildMruWindowList(kActiveDesk)) {
    AddWindow(window);
  }
}

AutoSnapController::~AutoSnapController() {
  window_observations_.RemoveAllObservations();
  Shell::Get()->activation_client()->RemoveObserver(this);
}

void AutoSnapController::OnWindowActivating(ActivationReason reason,
                                            aura::Window* gained_active,
                                            aura::Window* lost_active) {
  // If `gained_active` was activated as a side effect of a window disposition
  // change, do nothing. For example, when a snapped window is closed, another
  // window will be activated before `OnWindowDestroying()` is called. We should
  // not try to snap another window in this case.
  if (!gained_active ||
      reason == ActivationReason::WINDOW_DISPOSITION_CHANGED) {
    return;
  }

  AutoSnapWindowIfNeeded(gained_active);
}

void AutoSnapController::OnWindowVisibilityChanging(aura::Window* window,
                                                    bool visible) {
  // When a minimized window's visibility changes from invisible to visible or
  // is about to activate, it triggers an implicit un-minimizing (e.g.
  // `WorkspaceLayoutManager::OnChildWindowVisibilityChanged()` or
  // `WorkspaceLayoutManager::OnWindowActivating()`). This emits a window
  // state change event but it is unnecessary for to-be-snapped windows
  // because some clients (e.g. ARC app) handle a window state change
  // asynchronously. So in the case, we here try to snap a window before
  // other's handling to avoid the implicit un-minimizing.

  // Auto snapping is applicable for window changed to be visible.
  if (!visible) {
    return;
  }

  // Already un-minimized windows are not applicable for auto snapping.
  if (auto* window_state = WindowState::Get(window);
      !window_state || !window_state->IsMinimized()) {
    return;
  }

  // Visibility changes while restoring windows after dragged is transient
  // hide & show operations so not applicable for auto snapping.
  if (window->GetProperty(kHideDuringWindowDragging)) {
    return;
  }

  AutoSnapWindowIfNeeded(window);
}

void AutoSnapController::OnWindowAddedToRootWindow(aura::Window* window) {
  AddWindow(window);
}

void AutoSnapController::OnWindowRemovingFromRootWindow(
    aura::Window* window,
    aura::Window* new_root) {
  RemoveWindow(window);
}

void AutoSnapController::OnWindowDestroying(aura::Window* window) {
  RemoveWindow(window);
  if (window == root_window_) {
    // The root window is destroyed asynchronously with RootWindowController,
    // which owns SplitViewController, which owns `this`, so `root_window_` must
    // be reset earlier, in `OnWindowDestroying()`.
    root_window_ = nullptr;
  }
}

bool AutoSnapController::AutoSnapWindowIfNeeded(aura::Window* window) {
  CHECK(window);

  if (window->GetRootWindow() != root_window_) {
    return false;
  }

  OverviewController* overview_controller = OverviewController::Get();
  if (auto* overview_session = overview_controller->overview_session();
      overview_session && overview_session->is_shutting_down()) {
    // `OverviewSession::Shutdown()` may restore window activation and trigger
    // this; do not auto snap in this case.
    return false;
  }

  auto* split_view_controller = SplitViewController::Get(window);
  if (!split_view_controller->InSplitViewMode()) {
    // A window may be activated during mid-drag, during which split view is not
    // active yet.
    return false;
  }

  WindowState* window_state = WindowState::Get(window);
  const SplitViewController::State state = split_view_controller->state();
  // If `window` is floated on top of 2 already snapped windows (this can
  // happen after floating a window, starting split view, and activating
  // an unfloated window from overview), don't snap.
  if (window_state->IsFloated() &&
      state == SplitViewController::State::kBothSnapped) {
    return false;
  }

  if (DesksController::Get()->AreDesksBeingModified()) {
    // Activating a desk from its mini view will activate its most-recently
    // used window, but this should not result in snapping and ending overview
    // mode now. Overview will be ended explicitly as part of the desk
    // activation animation.
    return false;
  }

  // Only windows that are in the MRU list and are not already in split view can
  // be auto-snapped.
  if (split_view_controller->IsWindowInSplitView(window) ||
      !base::Contains(
          Shell::Get()->mru_window_tracker()->BuildMruWindowList(kActiveDesk),
          window)) {
    return false;
  }

  // If `window` is in transitional state (i.e. it's going to be snapped very
  // soon), no need to request another snap request. Also, no need to end
  // overview mode here because `OverviewGrid::OnSplitViewStateChanged()` will
  // handle it when the snapped state is applied.
  if (split_view_controller->IsWindowInTransitionalState(window)) {
    return false;
  }

  if (split_view_controller->InClamshellSplitViewMode() &&
      overview_controller->InOverviewSession() &&
      !overview_controller->overview_session()->IsWindowInOverview(window) &&
      !window_util::IsInFasterSplitScreenSetupSession(window)) {
    // Do not auto snap windows in clamshell splitview mode if a new window
    // is activated when clamshell splitview mode is active unless in faster
    // split screen setup session.
    overview_controller->EndOverview(OverviewEndAction::kSplitView);
    return false;
  }

  // Do not snap the window if the activation change is caused by dragging a
  // window.
  if (window_state->is_dragged()) {
    return false;
  }

  // If the divider is animating, then `window` cannot be snapped (and is
  // not already snapped either, because then we would have bailed out by
  // now). Then if `window` is user-positionable, we should end split view
  // mode, but the cannot snap toast would be inappropriate because the user
  // still might be able to snap `window`.
  if (split_view_controller->IsDividerAnimating()) {
    if (window_state->IsUserPositionable()) {
      split_view_controller->EndSplitView(
          SplitViewController::EndReason::kUnsnappableWindowActivated);
    }
    return false;
  }

  std::optional<float> auto_snap_ratio =
      CalculateAutoSnapRatio(split_view_controller, window);
  // If it's a user positionable window but can't be snapped, end split view
  // mode and show the cannot snap toast.
  if (!auto_snap_ratio) {
    if (window_state->IsUserPositionable()) {
      split_view_controller->EndSplitView(
          SplitViewController::EndReason::kUnsnappableWindowActivated);
      ShowAppCannotSnapToast();
    }
    return false;
  }
  if (!split_view_controller->CanSnapWindow(window, *auto_snap_ratio)) {
    // If the window can't fit in `auto_snap_ratio`, use its minimum size
    // instead.
    // TODO(sophiewen): See if we can do this without recalculating the snap
    // ratio and divider position.
    auto_snap_ratio.emplace(static_cast<float>(GetMinimumWindowLength(
                                window, IsLayoutHorizontal(window))) /
                            static_cast<float>(GetDividerPositionUpperLimit(
                                window->GetRootWindow())));
  }
  // Snap the window on the non-default side of the screen if split view mode
  // is active.
  split_view_controller->SnapWindow(
      window,
      (split_view_controller->default_snap_position() == SnapPosition::kPrimary)
          ? SnapPosition::kSecondary
          : SnapPosition::kPrimary,
      WindowSnapActionSource::kAutoSnapInSplitView,
      /*activate_window=*/false, *auto_snap_ratio);
  return true;
}

void AutoSnapController::AddWindow(aura::Window* window) {
  if (window->GetRootWindow() != root_window_) {
    return;
  }

  if (!window_observations_.IsObservingSource(window)) {
    window_observations_.AddObservation(window);
  }
}

void AutoSnapController::RemoveWindow(aura::Window* window) {
  window_observations_.RemoveObservation(window);
}

}  // namespace ash