chromium/ash/wm/pip/pip_window_resizer.cc

// Copyright 2018 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/pip/pip_window_resizer.h"

#include <algorithm>
#include <utility>

#include "ash/constants/ash_features.h"
#include "ash/metrics/pip_uma.h"
#include "ash/public/cpp/shell_window_ids.h"
#include "ash/shell.h"
#include "ash/wm/collision_detection/collision_detection_utils.h"
#include "ash/wm/pip/pip_controller.h"
#include "ash/wm/pip/pip_positioner.h"
#include "ash/wm/toplevel_window_event_handler.h"
#include "ash/wm/window_util.h"
#include "ash/wm/wm_event.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "ui/aura/client/aura_constants.h"
#include "ui/aura/window.h"
#include "ui/aura/window_delegate.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/scoped_layer_animation_settings.h"
#include "ui/display/screen.h"
#include "ui/gfx/geometry/resize_utils.h"
#include "ui/gfx/geometry/size_f.h"
#include "ui/gfx/geometry/transform_util.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/widget/widget.h"
#include "ui/wm/core/coordinate_conversion.h"

namespace ash {

namespace {
// Radius in which the touch can move in a non-dismiss direction before we
// no longer consider this gesture as a candidate for swipe-to-dismiss.
const int kPipDismissSlop = 8;
// How much area by proportion needs to be off-screen to consider this
// a dismissal during swipe-to-dismiss.
const float kPipDismissFraction = 0.5f;
// TODO(edcourtney): Consider varying the animation duration based on how far
// the pip window has to move.
const int kPipSnapToEdgeAnimationDurationMs = 150;
// Threshold for considering drag-moving a PIP window to fling in the
// direction of movement in GestureEvent velocity units.
const int kPipMovementFlingThresholdSquared = 1000 * 1000;
// Threshold for considering a swipe off the side of the screen a dismissal
// even if less than |kPipDismissFraction| of the PIP window is off-screen.
const int kPipSwipeToDismissFlingThresholdSquared = 800 * 800;

bool IsAtTopOrBottomEdge(const gfx::Rect& bounds, const gfx::Rect& area) {
  return (bounds.y() < area.y() + kPipDismissSlop && bounds.y() >= area.y()) ||
         (bounds.bottom() > area.bottom() - kPipDismissSlop &&
          bounds.bottom() <= area.bottom());
}

bool IsPastTopOrBottomEdge(const gfx::Rect& bounds, const gfx::Rect& area) {
  return bounds.y() < area.y() || bounds.bottom() > area.bottom();
}

bool IsAtLeftOrRightEdge(const gfx::Rect& bounds, const gfx::Rect& area) {
  return (bounds.x() < area.x() + kPipDismissSlop && bounds.x() >= area.x()) ||
         (bounds.right() > area.right() - kPipDismissSlop &&
          bounds.right() <= area.right());
}

bool IsPastLeftOrRightEdge(const gfx::Rect& bounds, const gfx::Rect& area) {
  return bounds.x() < area.x() || bounds.right() > area.right();
}

}  // namespace

PipWindowResizer::PipWindowResizer(WindowState* window_state)
    : WindowResizer(window_state) {
  window_state->OnDragStarted(details().window_component);

  // TODO(b/301232629): `DragDetails::bounds_change` should be
  // `kBoundsChange_Resizes` during pinch resizing.
  bool is_resize = (details().bounds_change & kBoundsChange_Resizes) ||
                   Shell::Get()->toplevel_window_event_handler()->in_pinch();
  if (is_resize) {
    UMA_HISTOGRAM_ENUMERATION(kAshPipEventsHistogramName,
                              AshPipEvents::FREE_RESIZE);
  } else {
    // Don't allow swipe-to-dismiss for resizes.
    gfx::Rect area =
        CollisionDetectionUtils::GetMovementArea(window_state->GetDisplay());
    // Check in which directions we can dismiss. Usually this is only in one
    // direction, except when the PIP window is in the corner. In that case,
    // we initially mark both directions as viable, and later choose one based
    // on the direction of drag.
    may_dismiss_horizontally_ =
        IsAtLeftOrRightEdge(GetTarget()->GetBoundsInScreen(), area);
    may_dismiss_vertically_ =
        IsAtTopOrBottomEdge(GetTarget()->GetBoundsInScreen(), area);
  }
}

PipWindowResizer::~PipWindowResizer() {
  // Drag details should be deleted upon destruction of the resizer.
  if (window_state())
    window_state()->DeleteDragDetails();
}

// TODO(edcourtney): Implement swipe-to-dismiss on fling.
void PipWindowResizer::Drag(const gfx::PointF& location_in_parent,
                            int event_flags) {
  last_location_in_screen_ = location_in_parent;
  last_event_was_pinch_ = false;
  ::wm::ConvertPointToScreen(GetTarget()->parent(),
                             &last_location_in_screen_.value());

  gfx::Vector2dF movement_direction =
      location_in_parent - details().initial_location_in_parent;
  // If we are not sure if this is a swipe or not yet, don't modify any bounds.
  float movement_distance2 = movement_direction.x() * movement_direction.x() +
                             movement_direction.y() * movement_direction.y();
  if ((may_dismiss_horizontally_ || may_dismiss_vertically_) &&
      movement_distance2 <= kPipDismissSlop * kPipDismissSlop) {
    return;
  }

  gfx::Rect new_bounds = CalculateBoundsForDrag(location_in_parent);
  // We do everything in Screen coordinates, so convert here.
  ::wm::ConvertRectToScreen(GetTarget()->parent(), &new_bounds);

  display::Display display = window_state()->GetDisplay();
  gfx::Rect area = CollisionDetectionUtils::GetMovementArea(display);

  // If the PIP window is at a corner, lock swipe to dismiss to the axis
  // of movement. Require that the direction of movement is mainly in the
  // direction of dismissing to start a swipe-to-dismiss gesture.
  if (in_screen_fraction_ == 1.f) {
    bool swipe_is_horizontal =
        std::abs(movement_direction.x()) > std::abs(movement_direction.y());
    may_dismiss_horizontally_ =
        may_dismiss_horizontally_ && swipe_is_horizontal;
    may_dismiss_vertically_ = may_dismiss_vertically_ && !swipe_is_horizontal;
  }

  // Lock to the axis if we've started the swipe-to-dismiss, or, if the PIP
  // window is no longer poking outside of the movement area, disable any
  // further swipe-to-dismiss gesture for this drag. Use the initial bounds
  // to decide the locked axis position.
  gfx::Rect initial_bounds_in_screen = details().initial_bounds_in_parent;
  ::wm::ConvertRectToScreen(GetTarget()->parent(), &initial_bounds_in_screen);
  if (may_dismiss_horizontally_) {
    if (IsPastLeftOrRightEdge(new_bounds, area))
      new_bounds.set_y(initial_bounds_in_screen.y());
    else if (!IsAtLeftOrRightEdge(new_bounds, area))
      may_dismiss_horizontally_ = false;
  } else if (may_dismiss_vertically_) {
    if (IsPastTopOrBottomEdge(new_bounds, area))
      new_bounds.set_x(initial_bounds_in_screen.x());
    else if (!IsAtTopOrBottomEdge(new_bounds, area))
      may_dismiss_vertically_ = false;
  }

  // If we aren't dismissing, make sure to collide with objects.
  if (!may_dismiss_horizontally_ && !may_dismiss_vertically_) {
    // Reset opacity if it's not a dismiss gesture.
    Shell::Get()->pip_controller()->SetDimOpacity(0.f);
    GetTarget()->layer()->SetOpacity(1.f);
    new_bounds = PipPositioner::GetBoundsForDrag(display, new_bounds,
                                                 GetTarget()->transform());
  } else {
    gfx::Rect in_screen_bounds = new_bounds;
    in_screen_bounds.Intersect(area);
    float bounds_area = new_bounds.width() * new_bounds.height();
    float in_screen_area = in_screen_bounds.width() * in_screen_bounds.height();
    if (bounds_area != 0.f) {
      in_screen_fraction_ = in_screen_area / bounds_area;
      if (may_dismiss_horizontally_ && features::IsPipTuckEnabled()) {
        if (bounds_area != 0.f) {
          Shell::Get()->pip_controller()->SetDimOpacity(
              fmin(0.5f, 1 - in_screen_fraction_));
        }
      } else {
        GetTarget()->layer()->SetOpacity(in_screen_fraction_);
      }
    }
  }

  // If the user has dragged the PIP window more than kPipDismissSlop distance
  // and no dismiss gesture has begun, make it impossible to initiate one for
  // the rest of the drag.
  if (in_screen_fraction_ == 1.f &&
      movement_distance2 > kPipDismissSlop * kPipDismissSlop) {
    may_dismiss_horizontally_ = false;
    may_dismiss_vertically_ = false;
  }

  // Convert back to root window coordinates for setting bounds.
  ::wm::ConvertRectFromScreen(GetTarget()->parent(), &new_bounds);
  if (new_bounds != GetTarget()->bounds()) {
    moved_or_resized_ = true;
    SetBoundsDuringResize(new_bounds);
  }
}

void PipWindowResizer::Pinch(const gfx::PointF& location_in_parent,
                             const float scale) {
  accumulated_scale_ *= scale;

  last_location_in_screen_ = location_in_parent;
  last_event_was_pinch_ = true;

  gfx::Rect new_bounds = CalculateBoundsForPinch(location_in_parent);

  // We do everything in screen coordinates, so convert here.
  wm::ConvertPointToScreen(GetTarget()->parent(),
                           &last_location_in_screen_.value());
  wm::ConvertRectToScreen(GetTarget()->parent(), &new_bounds);

  // Ensure that the PiP window stays inside the PiP movement area.
  // This has to be consistent with `PipWindowResizer::Drag()`, as otherwise
  // it can cause jump during transition from pinch to drag. This could be
  // due to change (b/292768858).
  display::Display display = window_state()->GetDisplay();
  new_bounds = PipPositioner::GetBoundsForDrag(display, new_bounds,
                                               GetTarget()->transform());

  // Convert back to root window coordinates for setting bounds.
  wm::ConvertRectFromScreen(GetTarget()->parent(), &new_bounds);
  if (new_bounds != GetTarget()->bounds()) {
    moved_or_resized_ = true;
    SetBoundsDuringResize(new_bounds);
  }
}

gfx::Rect PipWindowResizer::CalculateBoundsForPinch(
    const gfx::PointF& location_in_parent) const {
  const gfx::PointF initial_location = details().initial_location_in_parent;
  const gfx::Rect initial_bounds = details().initial_bounds_in_parent;

  gfx::Size size =
      gfx::ScaleToRoundedSize(initial_bounds.size(), accumulated_scale_);

  gfx::Size max_size = GetTarget()->delegate()->GetMaximumSize();
  gfx::Size min_size = GetTarget()->delegate()->GetMinimumSize();
  size.SetToMin(max_size);
  size.SetToMax(min_size);

  gfx::SizeF* aspect_ratio_size =
      GetTarget()->GetProperty(aura::client::kAspectRatio);
  // Aspect ratio must be set for pinch-to-resize to change window bounds.
  if (!aspect_ratio_size) {
    return initial_bounds;
  }
  float aspect_ratio = aspect_ratio_size->width() / aspect_ratio_size->height();

  gfx::Rect new_bounds(GetTarget()->bounds().origin(), size);
  gfx::SizeRectToAspectRatio(gfx::ResizeEdge::kBottom, aspect_ratio, min_size,
                             max_size, &new_bounds);

  // `gfx::SizeRectToAspectRatio()` is not designed for pinch and cannot
  // calculate origin change in regards to pinch, so we calculate the origin
  // change here.
  const float left_ratio =
      (initial_location.x() - initial_bounds.x()) / initial_bounds.width();
  const float top_ratio =
      (initial_location.y() - initial_bounds.y()) / initial_bounds.height();

  new_bounds.set_x(location_in_parent.x() -
                   new_bounds.width() *
                       GetTarget()->transform().To2dScale().x() * left_ratio);
  new_bounds.set_y(location_in_parent.y() -
                   new_bounds.height() *
                       GetTarget()->transform().To2dScale().y() * top_ratio);

  return new_bounds;
}

void PipWindowResizer::CompleteDrag() {
  window_state()->OnCompleteDrag(
      last_location_in_screen_.value_or(gfx::PointF()));

  window_state()->ClearRestoreBounds();
  window_state()->SetBoundsChangedByUser(moved_or_resized_);

  int fling_amount = fling_velocity_x_ * fling_velocity_x_ +
                     fling_velocity_y_ * fling_velocity_y_;
  // Trigger a dismiss if less than |kPipDismissFraction| of the PIP window area
  // is on-screen, or, if it was flung faster than
  // |kPipSwipeToDimissFlingThresholdSquared| during a dismiss gesture.
  bool should_dismiss_or_tuck =
      in_screen_fraction_ < kPipDismissFraction ||
      (in_screen_fraction_ != 1.f &&
       fling_amount >= kPipSwipeToDismissFlingThresholdSquared);
  bool should_tuck = should_dismiss_or_tuck && may_dismiss_horizontally_ &&
                     features::IsPipTuckEnabled();
  bool should_dismiss = should_dismiss_or_tuck && !should_tuck;

  if (should_dismiss) {
    // Close the widget. This will trigger an animation dismissing the PIP
    // window.
    window_util::CloseWidgetForWindow(window_state()->window());
  } else if (should_tuck) {
    const gfx::Point display_bounds_center =
        display::Screen::GetScreen()
            ->GetDisplayMatching(GetTarget()->bounds())
            .bounds()
            .CenterPoint();
    const gfx::Point center_point = GetTarget()->bounds().CenterPoint();
    bool left = center_point.x() < display_bounds_center.x();
    Shell::Get()->pip_controller()->TuckWindow(left);
  } else {
    // Animate the PIP window to its resting position.
    gfx::Rect intended_bounds;

    // The window should be flung when the gesture is not an edge resize.
    const bool is_edge_resize = details().bounds_change & kBoundsChange_Resizes;
    if (!is_edge_resize && fling_amount > kPipMovementFlingThresholdSquared) {
      intended_bounds = ComputeFlungPosition();
    } else {
      if (last_location_in_screen_.has_value()) {
        // To calculate the resting position, we want to use the user's
        // intended bounds (bounds that are not restricted by
        // obstacles).
        gfx::PointF location_in_parent = last_location_in_screen_.value();
        wm::ConvertPointFromScreen(GetTarget()->parent(), &location_in_parent);
        intended_bounds = last_event_was_pinch_
                              ? CalculateBoundsForPinch(location_in_parent)
                              : CalculateBoundsForDrag(location_in_parent);
        wm::ConvertRectToScreen(window_state()->window()->GetRootWindow(),
                                &intended_bounds);
      } else {
        intended_bounds = GetTarget()->GetBoundsInScreen();
      }
      // Reset the dimmer for tucking.
      Shell::Get()->pip_controller()->SetDimOpacity(0.f);
    }

    // Compute resting position even if it was a fling to avoid obstacles.
    gfx::Rect resting_bounds = CollisionDetectionUtils::GetRestingPosition(
        window_state()->GetDisplay(), intended_bounds,
        CollisionDetectionUtils::RelativePriority::kPictureInPicture);
    ::wm::ConvertRectFromScreen(GetTarget()->parent(), &resting_bounds);

    base::TimeDelta duration =
        base::Milliseconds(kPipSnapToEdgeAnimationDurationMs);
    SetBoundsWMEvent event(resting_bounds, /*animate=*/true, duration);
    window_state()->OnWMEvent(&event);

    ui::Layer* layer = GetTarget()->layer();
    ui::ScopedLayerAnimationSettings settings(layer->GetAnimator());
    settings.SetPreemptionStrategy(
        ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET);
    settings.SetTransitionDuration(duration);

    // Animate opacity back to normal opacity.
    layer->SetOpacity(1.f);

    // Animate the size back to its limit size if it has expanded or
    // shrunk beyond it.
    layer->SetTransform(gfx::Transform());

    // If the pip work area changes (e.g. message center, virtual keyboard),
    // we want to restore to the last explicitly set position.
    // TODO(edcourtney): This may not be the best place for this. Consider
    // doing this a different way or saving these bounds at a later point when
    // the work area changes.
    wm::ConvertRectToScreen(window_state()->window()->GetRootWindow(),
                                &resting_bounds);
    PipPositioner::SaveSnapFraction(window_state(), resting_bounds);
  }
}

void PipWindowResizer::RevertDrag() {
  // Handle cancel as a complete drag for pip. Having the PIP window
  // go back to where it was on cancel looks strange, so instead just
  // will just stop it where it is and animate to the edge of the screen.
  CompleteDrag();
}

void PipWindowResizer::FlingOrSwipe(ui::GestureEvent* event) {
  if (event->type() != ui::EventType::kScrollFlingStart &&
      event->type() != ui::EventType::kGestureSwipe) {
    return;
  }

  fling_velocity_x_ = event->details().velocity_x();
  fling_velocity_y_ = event->details().velocity_y();
  CompleteDrag();
}

gfx::Rect PipWindowResizer::ComputeFlungPosition() {
  gfx::Rect bounds = GetTarget()->GetBoundsInScreen();

  // Undefined fling direction, don't move.
  if (fling_velocity_x_ == 0 && fling_velocity_y_ == 0)
    return bounds;

  gfx::Rect area =
      CollisionDetectionUtils::GetMovementArea(window_state()->GetDisplay());

  // Compute signed distance to travel in x and y axes.
  int x_dist = 0;
  if (fling_velocity_x_ < 0)
    x_dist = area.x() - bounds.x();
  else if (fling_velocity_x_ > 0)
    x_dist = area.right() - bounds.x() - bounds.width();

  int y_dist = 0;
  if (fling_velocity_y_ < 0)
    y_dist = area.y() - bounds.y();
  else if (fling_velocity_y_ > 0)
    y_dist = area.bottom() - bounds.y() - bounds.height();

  // Compute which axis is limiting the movement, then offset.
  if (fling_velocity_x_ == 0 || std::abs(x_dist * fling_velocity_y_) >
                                    std::abs(y_dist * fling_velocity_x_)) {
    bounds.Offset((y_dist * fling_velocity_x_) / fling_velocity_y_, y_dist);
  } else {
    bounds.Offset(x_dist, (x_dist * fling_velocity_y_) / fling_velocity_x_);
  }

  return bounds;
}

}  // namespace ash