chromium/ash/wm/tile_group/window_splitter.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/tile_group/window_splitter.h"

#include <algorithm>
#include <memory>
#include <optional>
#include <set>

#include "ash/public/cpp/window_finder.h"
#include "ash/wm/mru_window_tracker.h"
#include "ash/wm/window_state.h"
#include "ash/wm/wm_event.h"
#include "ash/wm/wm_metrics.h"
#include "ash/wm/workspace/phantom_window_controller.h"
#include "base/functional/bind.h"
#include "base/metrics/histogram_functions.h"
#include "base/time/time.h"
#include "chromeos/ui/base/window_state_type.h"
#include "ui/aura/window.h"
#include "ui/aura/window_delegate.h"
#include "ui/display/screen.h"
#include "ui/events/velocity_tracker/motion_event.h"
#include "ui/events/velocity_tracker/motion_event_generic.h"
#include "ui/events/velocity_tracker/velocity_tracker.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/point_conversions.h"
#include "ui/gfx/geometry/point_f.h"
#include "ui/gfx/geometry/vector2d_f.h"

namespace ash {

namespace {

using DragType = WindowSplitter::DragType;
using SplitRegion = WindowSplitter::SplitRegion;
using SplitWindowInfo = WindowSplitter::SplitWindowInfo;

// Squared velocity value of the dwell max velocity, for calculations.
constexpr double kDwellMaxVelocitySquaredPixelsPerSec =
    WindowSplitter::kDwellMaxVelocityPixelsPerSec *
    WindowSplitter::kDwellMaxVelocityPixelsPerSec;

// Returns true if `split_region` is a splittable region.
bool IsRegionSplittable(SplitRegion split_region) {
  return split_region != SplitRegion::kNone;
}

// Returns true if the `window`'s state type allows the window to be split.
bool IsWindowStateTypeSplittable(aura::Window* window) {
  switch (WindowState::Get(window)->GetStateType()) {
    case chromeos::WindowStateType::kDefault:
    case chromeos::WindowStateType::kNormal:
    case chromeos::WindowStateType::kInactive:
    case chromeos::WindowStateType::kMaximized:
    case chromeos::WindowStateType::kPrimarySnapped:
    case chromeos::WindowStateType::kSecondarySnapped:
      return true;
    default:
      return false;
  }
}

aura::Window* GetTopmostWindow(aura::Window* dragged_window,
                               const gfx::PointF& screen_location) {
  const gfx::Point screen_point = gfx::ToFlooredPoint(screen_location);
  std::set<aura::Window*> ignore({dragged_window});
  while (true) {
    if (auto* topmost_window = GetTopmostWindowAtPoint(screen_point, ignore)) {
      // Some targeters slightly extend hit region outside window bounds, e.g.
      // `chromeos::kResizeOutsideBoundsSize`, so ignore those hits.
      if (!topmost_window->GetBoundsInScreen().Contains(screen_point)) {
        ignore.insert(topmost_window);
        continue;
      }
      if (CanIncludeWindowInMruList(topmost_window) &&
          IsWindowStateTypeSplittable(topmost_window)) {
        return topmost_window;
      }
    }
    return nullptr;
  }
}

gfx::Insets GetTriggerMargins(const gfx::Rect& bounds) {
  // TODO(b/293614784): Tune margin calculation.
  return gfx::Insets::VH(
      std::min(bounds.height() / 5, WindowSplitter::kBaseTriggerMargins.top()),
      std::min(bounds.width() / 5, WindowSplitter::kBaseTriggerMargins.left()));
}

// `screen_location` must be within `window`'s bounds.
SplitRegion GetSplitRegion(aura::Window* window,
                           const gfx::PointF& screen_location) {
  const gfx::Rect screen_bounds = window->GetBoundsInScreen();
  const gfx::Insets margins = GetTriggerMargins(screen_bounds);
  if (screen_location.x() < screen_bounds.x() + margins.left()) {
    return SplitRegion::kLeft;
  }
  if (screen_location.x() > screen_bounds.right() - margins.right()) {
    return SplitRegion::kRight;
  }
  if (screen_location.y() < screen_bounds.y() + margins.top()) {
    return SplitRegion::kTop;
  }
  if (screen_location.y() > screen_bounds.bottom() - margins.bottom()) {
    return SplitRegion::kBottom;
  }
  return SplitRegion::kNone;
}

// Gets the bounds after splitting `from_bounds` into the given region.
gfx::Rect GetBoundsForSplitRegion(const gfx::Rect& from_bounds,
                                  SplitRegion split_region) {
  gfx::Rect top_or_left = from_bounds;
  // Adjust size.
  switch (split_region) {
    case SplitRegion::kLeft:
    case SplitRegion::kRight:
      top_or_left.set_width(top_or_left.width() / 2);
      break;
    case SplitRegion::kTop:
    case SplitRegion::kBottom:
      top_or_left.set_height(top_or_left.height() / 2);
      break;
    default:
      break;
  }
  // Adjust position.
  switch (split_region) {
    case SplitRegion::kLeft:
    case SplitRegion::kTop:
      return top_or_left;
    case SplitRegion::kRight:
    case SplitRegion::kBottom: {
      gfx::Rect bottom_or_right = from_bounds;
      bottom_or_right.Subtract(top_or_left);
      return bottom_or_right;
    }
    default:
      break;
  }
  return from_bounds;
}

bool FitsMinimumSize(aura::Window* window, const gfx::Rect& new_size) {
  gfx::Size min_size;
  if (window->delegate()) {
    min_size = window->delegate()->GetMinimumSize();
  }
  if (!min_size.IsEmpty()) {
    return new_size.width() >= min_size.width() &&
           new_size.height() >= min_size.height();
  }
  return true;
}

bool ContainedInWorkArea(aura::Window* window) {
  return display::Screen::GetScreen()
      ->GetDisplayNearestWindow(window)
      .work_area()
      .Contains(window->GetBoundsInScreen());
}

void ResizeAndActivateWindow(aura::Window* window,
                             const gfx::Rect& screen_bounds) {
  auto* window_state = WindowState::Get(window);
  if (!chromeos::IsNormalWindowStateType(window_state->GetStateType())) {
    // TODO(b/308194482): Disable animation, e.g. if this would unmaximize.
    // But having animation may be ok, so need UX input.
    const WMEvent event(WM_EVENT_NORMAL);
    window_state->OnWMEvent(&event);
  }
  window->SetBoundsInScreen(
      screen_bounds,
      display::Screen::GetScreen()->GetDisplayMatching(screen_bounds));
  window_state->Activate();
}

}  // namespace

bool SplitWindowInfo::operator==(const SplitWindowInfo&) const = default;

std::optional<SplitWindowInfo> WindowSplitter::MaybeSplitWindow(
    aura::Window* topmost_window,
    aura::Window* dragged_window,
    const gfx::PointF& screen_location) {
  // Don't split if `topmost_window` is not fully inside a display's work area.
  // This gets around some corner cases, where the split window may end up
  // entirely off screen.
  if (!ContainedInWorkArea(topmost_window)) {
    return std::nullopt;
  }

  // TODO(b/342672204): Consider filtering out windows that are too occluded.

  const auto split_region = GetSplitRegion(topmost_window, screen_location);
  if (!IsRegionSplittable(split_region)) {
    return std::nullopt;
  }

  SplitWindowInfo split_info{
      .split_region = split_region,
  };
  split_info.topmost_window_bounds = topmost_window->GetBoundsInScreen();
  split_info.dragged_window_bounds =
      GetBoundsForSplitRegion(split_info.topmost_window_bounds, split_region);

  if (!FitsMinimumSize(dragged_window, split_info.dragged_window_bounds)) {
    return std::nullopt;
  }

  split_info.topmost_window_bounds.Subtract(split_info.dragged_window_bounds);

  if (!FitsMinimumSize(topmost_window, split_info.topmost_window_bounds)) {
    return std::nullopt;
  }

  return split_info;
}

WindowSplitter::WindowSplitter(aura::Window* dragged_window)
    : drag_start_time_(base::TimeTicks::Now()),
      velocity_tracker_(ui::VelocityTracker::Strategy::STRATEGY_DEFAULT) {
  dragged_window_observation_.Observe(dragged_window);
}

WindowSplitter::~WindowSplitter() {
  RecordMetricsOnEndDrag();
}

void WindowSplitter::UpdateDrag(const gfx::PointF& location_in_screen,
                                bool can_split) {
  is_drag_updated_ = true;
  last_location_in_screen_ = location_in_screen;

  // Must update cursor location every time, so the velocity is more accurate.
  UpdateCursorLocation(location_in_screen);

  if (!can_split || !dragged_window()) {
    Disengage();
    return;
  }

  const auto* last_topmost_window = topmost_window();
  UpdateTopMostWindow(GetTopmostWindow(dragged_window(), location_in_screen));
  if (!topmost_window() || topmost_window() != last_topmost_window) {
    RestartDwellTimer();
    return;
  }

  auto last_split_window_info = last_split_window_info_;
  const std::optional<SplitWindowInfo> split_bounds =
      MaybeSplitWindow(topmost_window(), dragged_window(), location_in_screen);
  last_split_window_info_ = split_bounds;
  if (!split_bounds || split_bounds != last_split_window_info) {
    RestartDwellTimer();
    return;
  }

  if (GetCursorVelocitySquared() > kDwellMaxVelocitySquaredPixelsPerSec) {
    RestartDwellTimer();
    return;
  }

  if (!ReadyToSplit() && !dwell_activation_timer_.IsRunning()) {
    RestartDwellTimer();
  }
}

void WindowSplitter::CompleteDrag(const gfx::PointF& last_location_in_screen) {
  is_drag_completed_ = true;
  if (!ReadyToSplit() || !dragged_window()) {
    return;
  }

  if (auto* topmost_window =
          GetTopmostWindow(dragged_window(), last_location_in_screen)) {
    if (const std::optional<SplitWindowInfo> split_bounds = MaybeSplitWindow(
            topmost_window, dragged_window(), last_location_in_screen)) {
      ResizeAndActivateWindow(topmost_window,
                              split_bounds->topmost_window_bounds);
      ResizeAndActivateWindow(dragged_window(),
                              split_bounds->dragged_window_bounds);
      completed_split_region_ = split_bounds->split_region;
    }
  }
}

void WindowSplitter::Disengage() {
  RemovePhantomWindow();
  UpdateTopMostWindow(nullptr);
  last_split_window_info_ = std::nullopt;
  // Don't clear velocity_tracker_, since it needs historical cursor positions
  // to be accurate.
}

void WindowSplitter::OnWindowDestroying(aura::Window* window) {
  if (window == topmost_window()) {
    UpdateTopMostWindow(nullptr);
    return;
  }
  // Dragged window is destroying.
  Disengage();
  RecordMetricsOnEndDrag();
  dragged_window_observation_.Reset();
}

void WindowSplitter::RestartDwellTimer() {
  if (dwell_activation_timer_.IsRunning()) {
    dwell_activation_timer_.Reset();
    return;
  }
  RemovePhantomWindow();
  dwell_activation_timer_.Start(
      FROM_HERE, kDwellActivationDuration,
      base::BindOnce(&WindowSplitter::ShowPhantomWindowCallback,
                     weak_ptr_factory_.GetWeakPtr()));
}

void WindowSplitter::RemovePhantomWindow() {
  phantom_window_controller_.reset();
  dwell_activation_timer_.Stop();
  dwell_cancellation_timer_.Stop();
}

void WindowSplitter::ShowPhantomWindowCallback() {
  if (!dragged_window()) {
    return;
  }

  // Make sure the cursor is still over the expected topmost window, since the
  // initial topmost window may have been moved/resized, closed, or occluded.
  if (auto* current_topmost_window =
          GetTopmostWindow(dragged_window(), last_location_in_screen_)) {
    if (current_topmost_window != topmost_window()) {
      return;
    }
    // Recalculate phantom window bounds, since topmost window may have resized.
    if (const std::optional<SplitWindowInfo> split_bounds =
            MaybeSplitWindow(current_topmost_window, dragged_window(),
                             last_location_in_screen_)) {
      ShowPhantomWindow(split_bounds->dragged_window_bounds);
      dwell_cancellation_timer_.Start(
          FROM_HERE, kDwellCancellationDuration,
          base::BindOnce(&WindowSplitter::Disengage,
                         weak_ptr_factory_.GetWeakPtr()));
    }
  }
}

void WindowSplitter::ShowPhantomWindow(const gfx::Rect& bounds) {
  if (!phantom_window_controller_) {
    phantom_window_controller_ =
        std::make_unique<PhantomWindowController>(dragged_window());
  }
  if (phantom_window_controller_->GetTargetWindowBounds() != bounds) {
    phantom_window_shown_count_++;
  }
  phantom_window_controller_->Show(bounds);
}

void WindowSplitter::RecordMetricsOnEndDrag() {
  if (!dragged_window() || !is_drag_updated_) {
    return;
  }

  const DragType drag_type = GetDragType();
  base::UmaHistogramEnumeration(kWindowSplittingDragTypeHistogramName,
                                drag_type);

  if (drag_type == DragType::kIncomplete) {
    return;
  }

  base::UmaHistogramMediumTimes(
      drag_type == DragType::kNoSplit
          ? kWindowSplittingDragDurationPerNoSplitHistogramName
          : kWindowSplittingDragDurationPerSplitHistogramName,
      base::TimeTicks::Now() - drag_start_time_);
  base::UmaHistogramCounts100(
      drag_type == DragType::kNoSplit
          ? kWindowSplittingPreviewsShownCountPerNoSplitDragHistogramName
          : kWindowSplittingPreviewsShownCountPerSplitDragHistogramName,
      phantom_window_shown_count_);

  if (drag_type == DragType::kSplit) {
    base::UmaHistogramEnumeration(kWindowSplittingSplitRegionHistogramName,
                                  completed_split_region_);
  }
}

DragType WindowSplitter::GetDragType() const {
  if (!is_drag_completed_) {
    return DragType::kIncomplete;
  }
  return IsRegionSplittable(completed_split_region_) ? DragType::kSplit
                                                     : DragType::kNoSplit;
}

void WindowSplitter::UpdateCursorLocation(
    const gfx::PointF& location_in_screen) {
  const ui::MotionEventGeneric event(
      ui::MotionEvent::Action::MOVE, base::TimeTicks::Now(),
      ui::PointerProperties(location_in_screen.x(), location_in_screen.y(),
                            /*touch_major=*/0));
  velocity_tracker_.AddMovement(event);
}

double WindowSplitter::GetCursorVelocitySquared() const {
  gfx::Vector2dF velocity_vector;
  float dx, dy;
  if (velocity_tracker_.GetVelocity(/*id=*/0, &dx, &dy)) {
    velocity_vector.set_x(dx);
    velocity_vector.set_y(dy);
  }
  return velocity_vector.LengthSquared();
}

void WindowSplitter::UpdateTopMostWindow(aura::Window* topmost_window) {
  if (!topmost_window) {
    topmost_window_observation_.Reset();
    return;
  }
  if (topmost_window_observation_.IsObservingSource(topmost_window)) {
    return;
  }
  topmost_window_observation_.Reset();
  topmost_window_observation_.Observe(topmost_window);
}

}  // namespace ash