// 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