| // 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/pip/pip_controller.h" |
| |
| #include "ash/public/cpp/app_types_util.h" |
| #include "ash/public/cpp/shell_window_ids.h" |
| #include "ash/resources/vector_icons/vector_icons.h" |
| #include "ash/screen_util.h" |
| #include "ash/shell.h" |
| #include "ash/wm/collision_detection/collision_detection_utils.h" |
| #include "ash/wm/pip/pip_positioner.h" |
| #include "ash/wm/window_dimmer.h" |
| #include "ash/wm/window_util.h" |
| #include "ash/wm/wm_event.h" |
| #include "chromeos/ui/base/chromeos_ui_constants.h" |
| #include "ui/aura/client/aura_constants.h" |
| #include "ui/aura/window_delegate.h" |
| #include "ui/compositor/layer.h" |
| #include "ui/gfx/canvas.h" |
| #include "ui/gfx/geometry/resize_utils.h" |
| #include "ui/gfx/geometry/size.h" |
| #include "ui/gfx/paint_vector_icon.h" |
| #include "ui/wm/core/coordinate_conversion.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| // The maximum opacity for the `WindowDimmer`. |
| constexpr float kPipTuckDimMaximumOpacity = 0.5f; |
| constexpr base::TimeDelta kSizeChangeAnimationDuration = |
| base::Milliseconds(300); |
| constexpr float kDefaultPipSizeFromWorkAreaPercent = 0.20f; |
| |
| // Calculates the new size of a given buffer size while preserving the aspect |
| // ratio of the PiP window. |
| gfx::Size PreserveAspectRatio(aura::Window* pip_window, |
| const gfx::Size& buffer_size) { |
| gfx::Size max_size = |
| pip_window->delegate()->GetMaximumSize().value_or(gfx::Size()); |
| gfx::Size min_size = pip_window->delegate()->GetMinimumSize(); |
| gfx::Size buffer_size_clone = buffer_size; |
| |
| gfx::SizeF* aspect_ratio_size = |
| pip_window->GetProperty(aura::client::kAspectRatio); |
| |
| buffer_size_clone.SetToMin(max_size); |
| buffer_size_clone.SetToMax(min_size); |
| |
| float aspect_ratio = aspect_ratio_size->width() / aspect_ratio_size->height(); |
| gfx::Rect window_rect(pip_window->bounds().origin(), buffer_size_clone); |
| |
| gfx::SizeRectToAspectRatio(gfx::ResizeEdge::kBottomRight, aspect_ratio, |
| min_size, max_size, &window_rect); |
| |
| return window_rect.size(); |
| } |
| |
| gfx::Size GetMaxSize(WindowState* pip_window_state) { |
| // Calculates the max size of the PiP window preserving the aspect ratio. |
| gfx::Size max_size = |
| pip_window_state->window()->delegate()->GetMaximumSize().value_or( |
| gfx::Size()); |
| return PreserveAspectRatio(pip_window_state->window(), max_size); |
| } |
| |
| gfx::Size GetDefaultSize(WindowState* pip_window_state) { |
| gfx::Size work_area_size = |
| display::Screen::GetScreen() |
| ->GetDisplayNearestWindow(pip_window_state->window()->GetRootWindow()) |
| .work_area_size(); |
| |
| gfx::Size window_size = gfx::ScaleToRoundedSize( |
| work_area_size, kDefaultPipSizeFromWorkAreaPercent); |
| |
| return PreserveAspectRatio(pip_window_state->window(), window_size); |
| } |
| |
| bool IsAtMaxSize(int width, int height, const gfx::Size& max_size) { |
| return width == max_size.width() && height == max_size.height(); |
| } |
| |
| bool IsDoubleTap(const ui::Event& event) { |
| return (event.IsMouseEvent() && event.flags() & ui::EF_IS_DOUBLE_CLICK) || |
| (event.IsGestureEvent() && |
| event.AsGestureEvent()->details().tap_count() == 2); |
| } |
| |
| class PipScopedWindowTuckerDelegate : public ScopedWindowTucker::Delegate { |
| public: |
| explicit PipScopedWindowTuckerDelegate() {} |
| PipScopedWindowTuckerDelegate(const PipScopedWindowTuckerDelegate&) = delete; |
| PipScopedWindowTuckerDelegate& operator=( |
| const PipScopedWindowTuckerDelegate&) = delete; |
| ~PipScopedWindowTuckerDelegate() override = default; |
| |
| void PaintTuckHandle(gfx::Canvas* canvas, int width, bool left) override { |
| // Flip the canvas horizontally for `left` tuck handle. |
| if (left) { |
| canvas->Translate(gfx::Vector2d(width, 0)); |
| canvas->Scale(-1, 1); |
| } |
| |
| const gfx::ImageSkia& tuck_icon = gfx::CreateVectorIcon( |
| kTuckHandleChevronIcon, ScopedWindowTucker::kTuckHandleWidth, |
| SK_ColorWHITE); |
| canvas->DrawImageInt(tuck_icon, 0, 0); |
| } |
| |
| int ParentContainerId() const override { return kShellWindowId_PipContainer; } |
| |
| void UpdateWindowPosition(aura::Window* window, bool left) override { |
| const gfx::Rect work_area = |
| screen_util::GetDisplayWorkAreaBoundsInParent(window); |
| |
| gfx::Rect bounds_in_parent = window->bounds(); |
| int bounds_left; |
| if (Shell::Get()->pip_controller()->is_tucked()) { |
| if (left) { |
| bounds_left = |
| -bounds_in_parent.width() + ScopedWindowTucker::kTuckHandleWidth; |
| } else { |
| bounds_left = work_area.width() - ScopedWindowTucker::kTuckHandleWidth; |
| } |
| } else { |
| if (left) { |
| bounds_left = kCollisionWindowWorkAreaInsetsDp; |
| } else { |
| bounds_left = work_area.width() - window->bounds().width() - |
| kCollisionWindowWorkAreaInsetsDp; |
| } |
| } |
| |
| bounds_in_parent.set_origin(gfx::Point(bounds_left, bounds_in_parent.y())); |
| window->SetBounds(bounds_in_parent); |
| } |
| |
| void UntuckWindow(aura::Window* window) override { |
| Shell::Get()->pip_controller()->UntuckWindow(); |
| } |
| |
| void OnAnimateTuckEnded(aura::Window* window) override {} |
| |
| gfx::Rect GetTuckHandleBounds(bool left, |
| const gfx::Rect& window_bounds) const override { |
| const gfx::Point tuck_handle_origin = |
| left ? window_bounds.right_center() - |
| gfx::Vector2d(ScopedWindowTucker::kTuckHandleWidth, |
| ScopedWindowTucker::kTuckHandleHeight / 2) |
| : window_bounds.left_center() - |
| gfx::Vector2d(0, ScopedWindowTucker::kTuckHandleHeight / 2); |
| return gfx::Rect(tuck_handle_origin, |
| gfx::Size(ScopedWindowTucker::kTuckHandleWidth, |
| ScopedWindowTucker::kTuckHandleHeight)); |
| } |
| }; |
| |
| } // namespace |
| |
| PipController::PipController() = default; |
| PipController::~PipController() = default; |
| |
| void PipController::SetPipWindow(aura::Window* window) { |
| if (!window || pip_window_ == window) { |
| return; |
| } |
| |
| if (pip_window_) { |
| // As removing ARC/Lacros PiP is async, a new PiP could be created before |
| // the currently one is fully removed. |
| UnsetPipWindow(pip_window_); |
| } |
| |
| pip_window_ = window; |
| is_tucked_ = false; |
| scoped_window_tucker_.reset(); |
| dimmer_.reset(); |
| pip_window_observation_.Reset(); |
| pip_window_observation_.Observe(window); |
| } |
| |
| void PipController::UnsetPipWindow(aura::Window* window) { |
| if (!pip_window_ || pip_window_ != window) { |
| // This function can be called with one of window state, visibility, or |
| // existence changes, all of which are valid. |
| return; |
| } |
| pip_window_observation_.Reset(); |
| pip_window_ = nullptr; |
| scoped_window_tucker_.reset(); |
| is_tucked_ = false; |
| dimmer_.reset(); |
| } |
| |
| bool PipController::CanResizePip() { |
| if (!pip_window_) { |
| return false; |
| } |
| gfx::Size max_size = |
| pip_window_->delegate()->GetMaximumSize().value_or(gfx::Size()); |
| gfx::Size min_size = pip_window_->delegate()->GetMinimumSize(); |
| return !max_size.IsEmpty() && !min_size.IsEmpty() && |
| max_size.width() > min_size.width() && |
| max_size.height() > min_size.height(); |
| } |
| |
| void PipController::UpdatePipBounds() { |
| if (!pip_window_) { |
| // It's a bit hard for the caller of this function to tell when PiP is |
| // really active (v.s. A PiP window just exists), so allow calling this |
| // when not appropriate. |
| return; |
| } |
| if (is_tucked_) { |
| // If the window is tucked, we do not want to move it to the resting |
| // position. |
| return; |
| } |
| WindowState* window_state = WindowState::Get(pip_window_); |
| if (!ash::PipPositioner::HasSnapFraction(window_state) && |
| IsArcWindow(pip_window_)) { |
| // Prevent PiP bounds from being updated between window state change into |
| // PiP and initial bounds change for PiP. This is only needed for ARC |
| // because chrome PiP becomes visible only after both window state and |
| // initial bounds are set properly while in the case of ARC a normal visible |
| // window can trainsition to PiP. Also, in fact, this check is only valid |
| // for ARC PiP as the first timing snap fraction is set is different between |
| // chrome PiP and ARC PiP. |
| return; |
| } |
| gfx::Rect new_bounds = |
| PipPositioner::GetPositionAfterMovementAreaChange(window_state); |
| wm::ConvertRectFromScreen(pip_window_->GetRootWindow(), &new_bounds); |
| if (pip_window_->bounds() != new_bounds) { |
| SetBoundsWMEvent event(new_bounds, /*animate=*/true); |
| window_state->OnWMEvent(&event); |
| } |
| } |
| |
| void PipController::TuckWindow(bool left) { |
| CHECK(pip_window_); |
| SetDimOpacity(kPipTuckDimMaximumOpacity); |
| is_tucked_ = true; |
| scoped_window_tucker_ = std::make_unique<ScopedWindowTucker>( |
| std::make_unique<PipScopedWindowTuckerDelegate>(), pip_window_, left); |
| scoped_window_tucker_->AnimateTuck(); |
| } |
| |
| void PipController::OnUntuckAnimationEnded() { |
| scoped_window_tucker_.reset(); |
| } |
| |
| void PipController::UntuckWindow() { |
| CHECK(pip_window_); |
| |
| // The order here matters: `is_tucked_` must be set to true |
| // before `UpdateWindowPosition()` or `AnimateUntuck()` gets |
| // the untucked window bounds. |
| is_tucked_ = false; |
| |
| SetDimOpacity(0.f); |
| |
| if (scoped_window_tucker_) { |
| scoped_window_tucker_->AnimateUntuck( |
| base::BindOnce(&PipController::OnUntuckAnimationEnded, |
| weak_ptr_factory_.GetWeakPtr())); |
| } |
| } |
| |
| views::Widget* PipController::GetTuckHandleWidget() { |
| CHECK(scoped_window_tucker_); |
| return scoped_window_tucker_->tuck_handle_widget(); |
| } |
| |
| void PipController::SetDimOpacity(float opacity) { |
| if (!pip_window_) { |
| // This function is invoked during drag move, and PiP can get killed during |
| // drag move too. |
| return; |
| } |
| if (opacity == 0.f) { |
| if (dimmer_) { |
| dimmer_->window()->Hide(); |
| } |
| } else { |
| if (!dimmer_) { |
| // The dimmer is created when it is first needed. It is not created |
| // with `SetPipWindow()` because it is called in |
| // `OnPrePipStateChange()` before the window fully enters the PiP state. |
| dimmer_ = std::make_unique<WindowDimmer>(pip_window_); |
| dimmer_->SetDimOpacity(kPipTuckDimMaximumOpacity); |
| dimmer_->window()->layer()->SetIsFastRoundedCorner(true); |
| dimmer_->window()->layer()->SetRoundedCornerRadius( |
| gfx::RoundedCornersF(chromeos::kPipRoundedCornerRadius)); |
| } |
| dimmer_->SetDimOpacity(opacity); |
| dimmer_->window()->Show(); |
| } |
| } |
| |
| void PipController::OnWindowDestroying(aura::Window* window) { |
| // Ensure to clean up when PiP is gone especially in unit tests. |
| UnsetPipWindow(window); |
| } |
| |
| bool PipController::HandleDoubleTap(const ui::Event& event) { |
| return pip_size_switch_handler_.ProcessDoubleTapEvent(event); |
| } |
| |
| bool PipController::HandleKeyboardShortcut() { |
| return pip_size_switch_handler_.ProcessShortcutEvent(pip_window_); |
| } |
| |
| bool PipController::PipSizeSwitchHandler::ProcessDoubleTapEvent( |
| const ui::Event& event) { |
| if (!IsDoubleTap(event)) { |
| return false; |
| } |
| |
| aura::Window* target = static_cast<aura::Window*>(event.target()); |
| WindowState* window_state = WindowState::Get(target->GetToplevelWindow()); |
| |
| if (!window_state) { |
| return false; |
| } |
| |
| if (window_util::IsArcPipWindow(window_state->window())) { |
| return false; |
| } |
| |
| return ResizePip(window_state); |
| } |
| |
| bool PipController::PipSizeSwitchHandler::ProcessShortcutEvent(aura::Window* pip_window) { |
| WindowState* window_state = WindowState::Get(pip_window); |
| if (!window_state) { |
| return false; |
| } |
| return ResizePip(window_state); |
| } |
| |
| bool PipController::PipSizeSwitchHandler::ResizePip(WindowState* window_state) { |
| if (!window_state) { |
| return false; |
| } |
| |
| if (!window_state->IsPip()) { |
| return false; |
| } |
| |
| if (!Shell::Get()->pip_controller()->CanResizePip()) { |
| return false; |
| } |
| |
| gfx::Rect bounds = window_state->window()->bounds(); |
| |
| window_state->SetBoundsChangedByUser(true); |
| |
| gfx::Size calculated_max_size = GetMaxSize(window_state); |
| // If the window is not at max size, we will expand it. |
| if (IsAtMaxSize(bounds.width(), bounds.height(), calculated_max_size)) { |
| // If the PiP window is max from the result of a drag resize (without ever |
| // double tapping) then go back to default. |
| if (prev_bounds_.IsEmpty()) { |
| bounds = gfx::Rect(bounds.origin(), GetDefaultSize(window_state)); |
| } else { |
| bounds = prev_bounds_; |
| } |
| } else { // Is not max size. |
| // Save the bounds of the window prior to expanding. |
| prev_bounds_ = bounds; |
| bounds.set_size(calculated_max_size); |
| } |
| |
| // Note that WindowState will place the position of the PiP window in the |
| // correct location. |
| SetBoundsWMEvent bounds_event(bounds, /*animate=*/true, |
| kSizeChangeAnimationDuration); |
| window_state->OnWMEvent(&bounds_event); |
| return true; |
| } |
| |
| } // namespace ash |