blob: 02548e6ca1db4a6d3ebbc350c1e068434dffa1e5 [file] [log] [blame]
// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chromeos/ui/frame/caption_buttons/frame_size_button.h"
#include <memory>
#include "base/i18n/rtl.h"
#include "base/memory/raw_ptr.h"
#include "base/metrics/user_metrics.h"
#include "chromeos/ui/base/tablet_state.h"
#include "chromeos/ui/base/window_properties.h"
#include "chromeos/ui/frame/caption_buttons/snap_controller.h"
#include "chromeos/ui/frame/frame_utils.h"
#include "chromeos/ui/wm/features.h"
#include "ui/aura/window.h"
#include "ui/aura/window_observer.h"
#include "ui/base/hit_test.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/display/tablet_state.h"
#include "ui/gfx/animation/animation_delegate.h"
#include "ui/gfx/animation/slide_animation.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/geometry/vector2d.h"
#include "ui/views/animation/compositor_animation_runner.h"
#include "ui/views/widget/widget.h"
namespace chromeos {
namespace {
// The default delay between the user pressing the size button and the buttons
// adjacent to the size button morphing into buttons for snapping left and
// right.
const int kSetButtonsToSnapModeDelayMs = 150;
// The amount that a user can overshoot one of the caption buttons while in
// "snap mode" and keep the button hovered/pressed.
const int kMaxOvershootX = 200;
const int kMaxOvershootY = 50;
constexpr base::TimeDelta kPieAnimationPressDuration = base::Milliseconds(150);
constexpr base::TimeDelta kPieAnimationHoverDuration = base::Milliseconds(500);
// TODO(sammiequon): Update the color to match specs.
constexpr SkColor kPieColor = SkColorSetA(SK_ColorGRAY, 128);
// Returns true if a mouse drag while in "snap mode" at |location_in_screen|
// would hover/press |button| or keep it hovered/pressed.
bool HitTestButton(const views::FrameCaptionButton* button,
const gfx::Point& location_in_screen) {
gfx::Rect expanded_bounds_in_screen = button->GetBoundsInScreen();
if (button->GetState() == views::Button::STATE_HOVERED ||
button->GetState() == views::Button::STATE_PRESSED) {
expanded_bounds_in_screen.Inset(
gfx::Insets::VH(-kMaxOvershootY, -kMaxOvershootX));
}
return expanded_bounds_in_screen.Contains(location_in_screen);
}
SnapDirection GetSnapDirection(const views::FrameCaptionButton* to_hover) {
if (!to_hover)
return SnapDirection::kNone;
aura::Window* window = to_hover->GetWidget()->GetNativeWindow();
switch (to_hover->GetIcon()) {
case views::CAPTION_BUTTON_ICON_LEFT_TOP_SNAPPED:
return GetSnapDirectionForWindow(window, /*left_top=*/true);
case views::CAPTION_BUTTON_ICON_RIGHT_BOTTOM_SNAPPED:
return GetSnapDirectionForWindow(window, /*left_top=*/false);
case views::CAPTION_BUTTON_ICON_FLOAT:
case views::CAPTION_BUTTON_ICON_MAXIMIZE_RESTORE:
case views::CAPTION_BUTTON_ICON_MINIMIZE:
case views::CAPTION_BUTTON_ICON_CLOSE:
case views::CAPTION_BUTTON_ICON_BACK:
case views::CAPTION_BUTTON_ICON_LOCATION:
case views::CAPTION_BUTTON_ICON_MENU:
case views::CAPTION_BUTTON_ICON_ZOOM:
case views::CAPTION_BUTTON_ICON_CENTER:
case views::CAPTION_BUTTON_ICON_CUSTOM:
case views::CAPTION_BUTTON_ICON_COUNT:
NOTREACHED();
return SnapDirection::kNone;
}
}
} // namespace
// This class controls animating a pie on a parent button which indicates when
// long press or long hover will end.
class FrameSizeButton::PieAnimation : public gfx::SlideAnimation,
public gfx::AnimationDelegate {
public:
PieAnimation(base::TimeDelta duration,
base::OnceClosure on_animation_canceled,
base::OnceClosure on_animation_ended,
FrameSizeButton* button)
: gfx::SlideAnimation(this),
on_animation_canceled_(std::move(on_animation_canceled)),
on_animation_ended_(std::move(on_animation_ended)),
button_(button) {
// `SlideAnimation` is unaffected by debug tools such as
// "--ui-slow-animations" flag, so manually multiply the duration here.
SetSlideDuration(
ui::ScopedAnimationDurationScaleMode::duration_multiplier() * duration);
SetTweenType(gfx::Tween::LINEAR);
// Use a runner synced with the compositor.
gfx::AnimationContainer* container = new gfx::AnimationContainer();
container->SetAnimationRunner(
std::make_unique<views::CompositorAnimationRunner>(
button_->GetWidget()));
SetContainer(container);
Show();
}
PieAnimation(const PieAnimation&) = delete;
PieAnimation& operator=(const PieAnimation&) = delete;
~PieAnimation() override = default;
void Paint(gfx::Canvas* canvas) {
// Use the bounds of the inkdrop.
gfx::Rect bounds = button_->GetLocalBounds();
bounds.Inset(button_->GetInkdropInsets(bounds.size()));
// The pie is a filled arc which starts at the top and sweeps around
// clockwise.
const SkScalar start_angle = -90.f;
const SkScalar sweep_angle = 360.f * GetCurrentValue();
SkPath path;
path.moveTo(bounds.CenterPoint().x(), bounds.CenterPoint().y());
path.arcTo(gfx::RectToSkRect(bounds), start_angle, sweep_angle,
/*forceMoveTo=*/false);
path.close();
cc::PaintFlags flags;
flags.setColor(kPieColor);
flags.setAntiAlias(true);
flags.setStyle(cc::PaintFlags::kFill_Style);
canvas->DrawPath(path, flags);
}
// gfx::AnimationDelegate:
void AnimationEnded(const gfx::Animation* animation) override {
button_->SchedulePaint();
std::move(on_animation_ended_).Run();
}
void AnimationProgressed(const gfx::Animation* animation) override {
button_->SchedulePaint();
}
void AnimationCanceled(const gfx::Animation* animation) override {
std::move(on_animation_canceled_).Run();
}
private:
base::OnceClosure on_animation_canceled_;
base::OnceClosure on_animation_ended_;
// The button `this` is associated with. Unowned.
raw_ptr<FrameSizeButton> button_;
};
// The class to observe the to-be-snapped window during the waiting-for-snap
// mode. If the window's window state is changed or the window is put in
// overview during the waiting mode, cancel the snap.
class FrameSizeButton::SnappingWindowObserver : public aura::WindowObserver {
public:
SnappingWindowObserver(aura::Window* window, FrameSizeButton* size_button)
: window_(window), size_button_(size_button) {
window_->AddObserver(this);
}
SnappingWindowObserver(const SnappingWindowObserver&) = delete;
SnappingWindowObserver& operator=(const SnappingWindowObserver&) = delete;
~SnappingWindowObserver() override {
if (window_) {
window_->RemoveObserver(this);
window_ = nullptr;
}
}
// aura::WindowObserver:
void OnWindowPropertyChanged(aura::Window* window,
const void* key,
intptr_t old) override {
DCHECK_EQ(window_, window);
if ((key == chromeos::kIsShowingInOverviewKey &&
window_->GetProperty(chromeos::kIsShowingInOverviewKey)) ||
key == chromeos::kWindowStateTypeKey) {
// If the window is put in overview while we're in waiting-for-snapping
// mode, or the window's window state has changed, cancel the snap.
size_button_->CancelSnap();
}
}
void OnWindowDestroying(aura::Window* window) override {
DCHECK_EQ(window_, window);
window_->RemoveObserver(this);
window_ = nullptr;
size_button_->CancelSnap();
}
private:
raw_ptr<aura::Window> window_;
raw_ptr<FrameSizeButton> size_button_;
};
FrameSizeButton::FrameSizeButton(PressedCallback callback,
FrameSizeButtonDelegate* delegate)
: views::FrameCaptionButton(std::move(callback),
views::CAPTION_BUTTON_ICON_MAXIMIZE_RESTORE,
HTMAXBUTTON),
delegate_(delegate),
set_buttons_to_snap_mode_delay_ms_(kSetButtonsToSnapModeDelayMs) {
display_observer_.emplace(this);
}
FrameSizeButton::~FrameSizeButton() = default;
void FrameSizeButton::ShowMultitaskMenu() {
// Show Multitask Menu if float is enabled. Note here float flag is also used
// to represent other relatable UI/UX changes.
if (chromeos::wm::features::IsFloatWindowEnabled()) {
// Owned by the bubble which contains this view. If there is an existing
// bubble, it will be deactivated and then close and destroy itself.
auto* multitask_menu = new MultitaskMenu(/*anchor=*/this, GetWidget());
multitask_menu->ShowBubble();
}
}
bool FrameSizeButton::OnMousePressed(const ui::MouseEvent& event) {
// Note that this triggers `StateChanged()`, and we want the changes to
// `pie_animation_` below to come after `StateChanged()`.
views::FrameCaptionButton::OnMousePressed(event);
if (IsTriggerableEvent(event)) {
// Add a visual indicator of when snap mode will get triggered.
if (chromeos::wm::features::IsFloatWindowEnabled()) {
base::OnceClosure cancel_animation = base::BindOnce(
&FrameSizeButton::DestroyPieAnimation, base::Unretained(this));
base::OnceClosure show_multitask_menu = base::BindOnce(
&FrameSizeButton::OnPieAnimationCompleted, base::Unretained(this));
pie_animation_ = std::make_unique<PieAnimation>(
kPieAnimationPressDuration, std::move(cancel_animation),
std::move(show_multitask_menu), this);
}
// The minimize and close buttons are set to snap left and right when
// snapping is enabled. Do not enable snapping if the minimize button is not
// visible. The close button is always visible.
if (!in_snap_mode_ && delegate_->CanSnap() &&
delegate_->IsMinimizeButtonVisible()) {
StartSetButtonsToSnapModeTimer(event);
}
}
return true;
}
bool FrameSizeButton::OnMouseDragged(const ui::MouseEvent& event) {
UpdateSnapPreview(event);
// By default a FrameCaptionButton reverts to STATE_NORMAL once the mouse
// leaves its bounds. Skip FrameCaptionButton's handling when
// |in_snap_mode_| == true because we want different behavior.
if (!in_snap_mode_)
views::FrameCaptionButton::OnMouseDragged(event);
return true;
}
void FrameSizeButton::OnMouseReleased(const ui::MouseEvent& event) {
if (IsTriggerableEvent(event))
CommitSnap(event);
views::FrameCaptionButton::OnMouseReleased(event);
}
void FrameSizeButton::OnMouseCaptureLost() {
SetButtonsToNormalMode(FrameSizeButtonDelegate::Animate::kYes);
views::FrameCaptionButton::OnMouseCaptureLost();
}
void FrameSizeButton::OnMouseMoved(const ui::MouseEvent& event) {
// Ignore any synthetic mouse moves during a drag.
if (!in_snap_mode_)
views::FrameCaptionButton::OnMouseMoved(event);
}
void FrameSizeButton::OnGestureEvent(ui::GestureEvent* event) {
if (event->details().touch_points() > 1) {
SetButtonsToNormalMode(FrameSizeButtonDelegate::Animate::kYes);
return;
}
if (event->type() == ui::ET_GESTURE_TAP_DOWN && delegate_->CanSnap()) {
StartSetButtonsToSnapModeTimer(*event);
// Go through FrameCaptionButton's handling so that the button gets pressed.
views::FrameCaptionButton::OnGestureEvent(event);
// Add a visual indicator of when snap mode will get triggered. Note that
// order matters as the subclasses will call `StateChanged()` and we want
// the changes there to run first.
if (chromeos::wm::features::IsFloatWindowEnabled()) {
std::pair<base::OnceClosure, base::OnceClosure> split =
base::SplitOnceCallback(base::BindOnce(
&FrameSizeButton::DestroyPieAnimation, base::Unretained(this)));
pie_animation_ = std::make_unique<PieAnimation>(
kPieAnimationPressDuration, std::move(split.first),
std::move(split.second), this);
}
return;
}
if (event->type() == ui::ET_GESTURE_SCROLL_BEGIN ||
event->type() == ui::ET_GESTURE_SCROLL_UPDATE) {
UpdateSnapPreview(*event);
event->SetHandled();
return;
}
if (event->type() == ui::ET_GESTURE_TAP ||
event->type() == ui::ET_GESTURE_SCROLL_END ||
event->type() == ui::ET_SCROLL_FLING_START ||
event->type() == ui::ET_GESTURE_END) {
if (CommitSnap(*event)) {
event->SetHandled();
return;
}
}
views::FrameCaptionButton::OnGestureEvent(event);
}
void FrameSizeButton::StateChanged(views::Button::ButtonState old_state) {
if (!chromeos::wm::features::IsFloatWindowEnabled())
return;
if (GetState() == views::Button::STATE_HOVERED && GetWidget()->IsActive()) {
base::OnceClosure cancel_animation = base::BindOnce(
&FrameSizeButton::DestroyPieAnimation, base::Unretained(this));
base::OnceClosure show_multitask_menu = base::BindOnce(
&FrameSizeButton::OnPieAnimationCompleted, base::Unretained(this));
pie_animation_ = std::make_unique<PieAnimation>(
kPieAnimationHoverDuration, std::move(cancel_animation),
std::move(show_multitask_menu), this);
// On animation end we should show the multitask menu.
} else if (old_state == views::Button::STATE_HOVERED) {
pie_animation_.reset();
}
}
void FrameSizeButton::PaintButtonContents(gfx::Canvas* canvas) {
if (pie_animation_)
pie_animation_->Paint(canvas);
views::FrameCaptionButton::PaintButtonContents(canvas);
}
void FrameSizeButton::OnDisplayTabletStateChanged(display::TabletState state) {
if (state == display::TabletState::kEnteringTabletMode) {
pie_animation_.reset();
set_buttons_to_snap_mode_timer_.Stop();
}
}
void FrameSizeButton::StartSetButtonsToSnapModeTimer(
const ui::LocatedEvent& event) {
set_buttons_to_snap_mode_timer_event_location_ = event.location();
if (set_buttons_to_snap_mode_delay_ms_ == 0) {
AnimateButtonsToSnapMode();
} else {
set_buttons_to_snap_mode_timer_.Start(
FROM_HERE, base::Milliseconds(set_buttons_to_snap_mode_delay_ms_), this,
&FrameSizeButton::AnimateButtonsToSnapMode);
}
}
void FrameSizeButton::AnimateButtonsToSnapMode() {
SetButtonsToSnapMode(FrameSizeButtonDelegate::Animate::kYes);
// Start observing the to-be-snapped window.
snapping_window_observer_ = std::make_unique<SnappingWindowObserver>(
GetWidget()->GetNativeWindow(), this);
}
void FrameSizeButton::SetButtonsToSnapMode(
FrameSizeButtonDelegate::Animate animate) {
in_snap_mode_ = true;
// When using a right-to-left layout the close button is left of the size
// button and the minimize button is right of the size button.
if (base::i18n::IsRTL()) {
delegate_->SetButtonIcons(views::CAPTION_BUTTON_ICON_RIGHT_BOTTOM_SNAPPED,
views::CAPTION_BUTTON_ICON_LEFT_TOP_SNAPPED,
animate);
} else {
delegate_->SetButtonIcons(views::CAPTION_BUTTON_ICON_LEFT_TOP_SNAPPED,
views::CAPTION_BUTTON_ICON_RIGHT_BOTTOM_SNAPPED,
animate);
}
}
void FrameSizeButton::UpdateSnapPreview(const ui::LocatedEvent& event) {
if (!in_snap_mode_) {
// Set the buttons adjacent to the size button to snap left and right early
// if the user drags past the drag threshold.
// |set_buttons_to_snap_mode_timer_| is checked to avoid entering the snap
// mode as a result of an unsupported drag type (e.g. only the right mouse
// button is pressed).
gfx::Vector2d delta(event.location() -
set_buttons_to_snap_mode_timer_event_location_);
if (!set_buttons_to_snap_mode_timer_.IsRunning() ||
!views::View::ExceededDragThreshold(delta)) {
return;
}
AnimateButtonsToSnapMode();
}
const views::FrameCaptionButton* to_hover = GetButtonToHover(event);
SnapDirection snap = GetSnapDirection(to_hover);
gfx::Point event_location_in_screen(event.location());
views::View::ConvertPointToScreen(this, &event_location_in_screen);
bool press_size_button =
to_hover || HitTestButton(this, event_location_in_screen);
if (to_hover) {
// Progress the minimize and close icon morph animations to the end if they
// are in progress.
SetButtonsToSnapMode(FrameSizeButtonDelegate::Animate::kNo);
}
delegate_->SetHoveredAndPressedButtons(to_hover,
press_size_button ? this : nullptr);
delegate_->ShowSnapPreview(snap,
/*allow_haptic_feedback=*/event.IsMouseEvent());
}
const views::FrameCaptionButton* FrameSizeButton::GetButtonToHover(
const ui::LocatedEvent& event) const {
gfx::Point event_location_in_screen(event.location());
views::View::ConvertPointToScreen(this, &event_location_in_screen);
const views::FrameCaptionButton* closest_button =
delegate_->GetButtonClosestTo(event_location_in_screen);
if ((closest_button->GetIcon() ==
views::CAPTION_BUTTON_ICON_LEFT_TOP_SNAPPED ||
closest_button->GetIcon() ==
views::CAPTION_BUTTON_ICON_RIGHT_BOTTOM_SNAPPED) &&
HitTestButton(closest_button, event_location_in_screen)) {
return closest_button;
}
return nullptr;
}
bool FrameSizeButton::CommitSnap(const ui::LocatedEvent& event) {
snapping_window_observer_.reset();
SnapDirection snap = GetSnapDirection(GetButtonToHover(event));
delegate_->CommitSnap(snap);
delegate_->SetHoveredAndPressedButtons(nullptr, nullptr);
if (snap == SnapDirection::kPrimary) {
base::RecordAction(base::UserMetricsAction("MaxButton_MaxLeft"));
} else if (snap == SnapDirection::kSecondary) {
base::RecordAction(base::UserMetricsAction("MaxButton_MaxRight"));
} else {
SetButtonsToNormalMode(FrameSizeButtonDelegate::Animate::kYes);
return false;
}
SetButtonsToNormalMode(FrameSizeButtonDelegate::Animate::kNo);
return true;
}
void FrameSizeButton::CancelSnap() {
snapping_window_observer_.reset();
delegate_->CommitSnap(SnapDirection::kNone);
delegate_->SetHoveredAndPressedButtons(nullptr, nullptr);
SetButtonsToNormalMode(FrameSizeButtonDelegate::Animate::kYes);
}
void FrameSizeButton::SetButtonsToNormalMode(
FrameSizeButtonDelegate::Animate animate) {
in_snap_mode_ = false;
pie_animation_.reset();
set_buttons_to_snap_mode_timer_.Stop();
delegate_->SetButtonsToNormal(animate);
}
void FrameSizeButton::OnPieAnimationCompleted() {
ShowMultitaskMenu();
pie_animation_.reset();
}
void FrameSizeButton::DestroyPieAnimation() {
pie_animation_.reset();
}
BEGIN_METADATA(FrameSizeButton, views::FrameCaptionButton)
END_METADATA
} // namespace chromeos