chromium/ash/components/arc/compat_mode/resize_toggle_menu.cc

// Copyright 2021 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/components/arc/compat_mode/resize_toggle_menu.h"

#include "ash/components/arc/compat_mode/overlay_dialog.h"
#include "ash/components/arc/compat_mode/style/arc_color_provider.h"
#include "ash/public/cpp/arc_compat_mode_util.h"
#include "ash/public/cpp/style/color_provider.h"
#include "ash/public/cpp/window_properties.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/typography.h"
#include "base/check.h"
#include "base/functional/bind.h"
#include "base/functional/callback_forward.h"
#include "base/notreached.h"
#include "base/task/sequenced_task_runner.h"
#include "chromeos/constants/chromeos_features.h"
#include "components/strings/grit/components_strings.h"
#include "ui/aura/client/aura_constants.h"
#include "ui/aura/window.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/mojom/dialog_button.mojom.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/color/color_id.h"
#include "ui/color/color_provider.h"
#include "ui/compositor/layer.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/gfx/text_constants.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/highlight_border.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/style/platform_style.h"
#include "ui/views/widget/widget.h"

namespace arc {

namespace {

class RoundedCornerBubbleDialogDelegateView
    : public views::BubbleDialogDelegateView {
  METADATA_HEADER(RoundedCornerBubbleDialogDelegateView,
                  views::BubbleDialogDelegateView)

 public:
  explicit RoundedCornerBubbleDialogDelegateView(int corner_radius)
      : corner_radius_(corner_radius) {}

  // views::View:
  void AddedToWidget() override {
    auto* const frame = GetBubbleFrameView();
    if (frame)
      frame->SetCornerRadius(corner_radius_);
  }

  base::WeakPtr<RoundedCornerBubbleDialogDelegateView> GetWeakPtr() {
    return weak_factory_.GetWeakPtr();
  }

 private:
  const int corner_radius_;
  base::WeakPtrFactory<RoundedCornerBubbleDialogDelegateView> weak_factory_{
      this};
};

BEGIN_METADATA(RoundedCornerBubbleDialogDelegateView)
END_METADATA

}  // namespace

ResizeToggleMenu::MenuButtonView::MenuButtonView(PressedCallback callback,
                                                 const gfx::VectorIcon& icon,
                                                 int title_string_id)
    : views::Button(std::move(callback)),
      icon_(icon),
      title_string_id_(title_string_id) {
  // Don't use FlexLayout here because it breaks the focus ring's bounds.
  // TODO(b/193195191): Investigate why we can't use FlexLayout.
  SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical,
      gfx::Insets::TLBR(16, 0, 12, 0)));

  AddChildView(
      views::Builder<views::ImageView>()
          .CopyAddressTo(&icon_view_)
          .SetImageSize(gfx::Size(20, 20))
          .SetHorizontalAlignment(views::ImageView::Alignment::kCenter)
          .SetVerticalAlignment(views::ImageView::Alignment::kCenter)
          .SetProperty(views::kMarginsKey, gfx::Insets::TLBR(0, 0, 8, 0))
          .Build());
  const raw_ptr<views::Label> label =
      AddChildView(views::Builder<views::Label>()
                       .CopyAddressTo(&title_)
                       .SetBackgroundColor(SK_ColorTRANSPARENT)
                       .SetText(l10n_util::GetStringUTF16(title_string_id))
                       .SetVerticalAlignment(gfx::ALIGN_BOTTOM)
                       .SetLineHeight(16)
                       .SetMultiLine(true)
                       .SetAllowCharacterBreak(true)
                       .Build());
  ash::TypographyProvider::Get()->StyleLabel(ash::TypographyToken::kCrosButton2,
                                             *label);

  constexpr int kBorderThicknessDp = 1;
  const auto button_radius = 12;
  SetBorder(views::CreateRoundedRectBorder(kBorderThicknessDp, button_radius,
                                           gfx::kPlaceholderColor));
  SetBackground(views::CreateRoundedRectBackground(gfx::kPlaceholderColor,
                                                   button_radius));

  const int focus_ring_radius = 16;
  // With Jellyroll, the ring should have a 4dp gap from the view. Setting a
  // negative inset makes insets "outsets".
  const int focus_ring_inset = -4;
  SetFocusBehavior(FocusBehavior::ALWAYS);
  SetInstallFocusRingOnFocus(true);
  views::InstallRoundRectHighlightPathGenerator(
      this, gfx::Insets(focus_ring_inset), focus_ring_radius);

  views::FocusRing::Get(this)->SetColorId(cros_tokens::kCrosSysFocusRing);

  GetViewAccessibility().SetRole(ax::mojom::Role::kMenuItem);
  GetViewAccessibility().SetName(l10n_util::GetStringUTF16(title_string_id_));
}

ResizeToggleMenu::MenuButtonView::~MenuButtonView() = default;

void ResizeToggleMenu::MenuButtonView::SetSelected(bool is_selected) {
  is_selected_ = is_selected;
  SetState(is_selected_ ? views::Button::ButtonState::STATE_DISABLED
                        : views::Button::ButtonState::STATE_NORMAL);
  UpdateColors();
}

void ResizeToggleMenu::MenuButtonView::OnThemeChanged() {
  views::Button::OnThemeChanged();
  UpdateColors();
}

gfx::Size ResizeToggleMenu::MenuButtonView::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  constexpr int kWidth = 96;
  return gfx::Size(
      kWidth, GetLayoutManager()->GetPreferredHeightForWidth(this, kWidth));
}

void ResizeToggleMenu::MenuButtonView::UpdateColors() {
  if (!GetWidget())
    return;

  const auto* color_provider = GetColorProvider();

  const auto icon_color =
      is_selected_ ? color_provider->GetColor(cros_tokens::kCrosSysOnPrimary)
                   : color_provider->GetColor(cros_tokens::kCrosSysOnSurface);
  icon_view_->SetImage(gfx::CreateVectorIcon(*icon_, icon_color));

  const auto text_color =
      is_selected_ ? color_provider->GetColor(cros_tokens::kCrosSysOnPrimary)
                   : color_provider->GetColor(cros_tokens::kCrosSysOnSurface);
  title_->SetEnabledColor(text_color);

  const auto background_color =
      is_selected_
          ? color_provider->GetColor(cros_tokens::kCrosSysPrimary)
          : color_provider->GetColor(cros_tokens::kCrosSysSystemOnBase);
  background()->SetNativeControlColor(background_color);

  const auto border_color = SK_ColorTRANSPARENT;
  GetBorder()->set_color(border_color);
}

BEGIN_METADATA(ResizeToggleMenu, MenuButtonView)
END_METADATA

ResizeToggleMenu::ResizeToggleMenu(
    base::OnceClosure on_bubble_widget_closing_callback,
    views::Widget* widget,
    ArcResizeLockPrefDelegate* pref_delegate)
    : on_bubble_widget_closing_callback_(
          std::move(on_bubble_widget_closing_callback)),
      widget_(widget),
      pref_delegate_(pref_delegate) {
  aura::Window* const window = widget->GetNativeWindow();
  // Don't show the menu in maximized or fullscreen.
  const ui::WindowShowState state =
      window->GetProperty(aura::client::kShowStateKey);
  if (state == ui::SHOW_STATE_FULLSCREEN || state == ui::SHOW_STATE_MAXIMIZED)
    return;

  window_observation_.Observe(window);

  bubble_widget_ =
      views::BubbleDialogDelegateView::CreateBubble(MakeBubbleDelegateView(
          widget_, GetAnchorRect(),
          base::BindRepeating(&ResizeToggleMenu::ApplyResizeCompatMode,
                              base::Unretained(this))));
  widget_observations_.AddObservation(widget_.get());
  widget_observations_.AddObservation(bubble_widget_.get());
  OverlayDialog::Show(widget_->GetNativeWindow(),
                      base::BindOnce(&ResizeToggleMenu::CloseBubble,
                                     weak_ptr_factory_.GetWeakPtr()),
                      /*dialog_view=*/nullptr);
  bubble_widget_->Show();
}

ResizeToggleMenu::~ResizeToggleMenu() {
  CloseBubble();
}

void ResizeToggleMenu::OnWidgetClosing(views::Widget* widget) {
  if (widget == bubble_widget_ && on_bubble_widget_closing_callback_) {
    std::move(on_bubble_widget_closing_callback_).Run();
  }

  OverlayDialog::CloseIfAny(widget_->GetNativeWindow());
  widget_observations_.RemoveAllObservations();
  widget_ = nullptr;
  bubble_widget_ = nullptr;
}

void ResizeToggleMenu::OnWidgetBoundsChanged(views::Widget* widget,
                                             const gfx::Rect& new_bounds) {
  if (widget != widget_)
    return;

  DCHECK(bubble_widget_);
  bubble_widget_->widget_delegate()->AsBubbleDialogDelegate()->SetAnchorRect(
      GetAnchorRect());

  UpdateSelectedButton();
}

void ResizeToggleMenu::OnWindowPropertyChanged(aura::Window* window,
                                               const void* key,
                                               intptr_t old) {
  DCHECK(window_observation_.IsObservingSource(window));
  if (key == aura::client::kShowStateKey) {
    const ui::WindowShowState state =
        window->GetProperty(aura::client::kShowStateKey);
    if (state == ui::SHOW_STATE_FULLSCREEN || state == ui::SHOW_STATE_MAXIMIZED)
      CloseBubble();
  } else if (key == ash::kArcResizeLockTypeKey) {
    UpdateSelectedButton();
  }
}

void ResizeToggleMenu::OnWindowDestroying(aura::Window* window) {
  DCHECK(window_observation_.IsObservingSource(window));
  window_observation_.Reset();
}

gfx::Rect ResizeToggleMenu::GetAnchorRect() const {
  DCHECK(widget_);
  const gfx::Rect client_view_rect =
      widget_->client_view()->GetBoundsInScreen();
  // Anchored to the top edge of the client_view with padding.
  constexpr auto kMarginTopDp = 8;
  return gfx::Rect(client_view_rect.x(), client_view_rect.y() + kMarginTopDp,
                   client_view_rect.width(), 0);
}

std::unique_ptr<views::BubbleDialogDelegateView>
ResizeToggleMenu::MakeBubbleDelegateView(
    views::Widget* parent,
    gfx::Rect anchor_rect,
    base::RepeatingCallback<void(ash::ResizeCompatMode)> command_handler) {
  const int kCornerRadius = 12;

  auto delegate_view =
      std::make_unique<RoundedCornerBubbleDialogDelegateView>(kCornerRadius);
  bubble_view_ = delegate_view->GetWeakPtr();

  // Setup delegate.
  delegate_view->SetArrow(views::BubbleBorder::Arrow::TOP_CENTER);
  delegate_view->SetButtons(static_cast<int>(ui::mojom::DialogButton::kNone));
  delegate_view->set_parent_window(parent->GetNativeView());
  delegate_view->set_title_margins(gfx::Insets());
  delegate_view->set_margins(gfx::Insets());
  delegate_view->SetAnchorRect(anchor_rect);
  delegate_view->SetTitle(
      l10n_util::GetStringUTF16(IDS_ARC_COMPAT_MODE_RESIZE_TOGGLE_MENU_TITLE));
  delegate_view->SetShowTitle(false);
  delegate_view->SetAccessibleWindowRole(ax::mojom::Role::kMenu);
  // Clear root view's background color. We use the color in
  // `background_view`.
  delegate_view->set_color(SK_ColorTRANSPARENT);

  // Setup view.
  delegate_view->SetUseDefaultFillLayout(true);

  delegate_view->SetBorder(std::make_unique<views::HighlightBorder>(
      kCornerRadius, views::HighlightBorder::Type::kHighlightBorderNoShadow));

  // Add empty view for background blur.
  views::View* background_view = nullptr;
  delegate_view->AddChildView(
      views::Builder<views::View>()
          .CopyAddressTo(&background_view)
          .SetUseDefaultFillLayout(true)
          .SetBackground(views::CreateThemedRoundedRectBackground(
              cros_tokens::kCrosSysSystemBaseElevated, kCornerRadius))
          .Build());

  background_view->SetPaintToLayer();
  background_view->layer()->SetBackgroundBlur(
      ash::ColorProvider::kBackgroundBlurSigma);
  background_view->layer()->SetBackdropFilterQuality(
      ash::ColorProvider::kBackgroundBlurQuality);
  background_view->layer()->SetRoundedCornerRadius(
      gfx::RoundedCornersF(kCornerRadius));
  background_view->layer()->SetFillsBoundsOpaquely(false);

  auto* const container_view =
      delegate_view->AddChildView(std::make_unique<views::View>());

  auto* const provider = views::LayoutProvider::Get();
  container_view->SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kHorizontal, gfx::Insets(16),
      provider->GetDistanceMetric(views::DISTANCE_RELATED_BUTTON_HORIZONTAL)));

  const auto add_menu_button = [&container_view, &command_handler](
                                   ash::ResizeCompatMode command_id,
                                   const gfx::VectorIcon& icon, int string_id) {
    return container_view->AddChildView(std::make_unique<MenuButtonView>(
        base::BindRepeating(command_handler, command_id), icon, string_id));
  };
  phone_button_ =
      add_menu_button(ash::ResizeCompatMode::kPhone, ash::kSystemMenuPhoneIcon,
                      IDS_ARC_COMPAT_MODE_RESIZE_TOGGLE_MENU_PORTRAIT);
  tablet_button_ = add_menu_button(
      ash::ResizeCompatMode::kTablet, ash::kSystemMenuTabletIcon,
      IDS_ARC_COMPAT_MODE_RESIZE_TOGGLE_MENU_LANDSCAPE);
  resizable_button_ = add_menu_button(
      ash::ResizeCompatMode::kResizable, ash::kAppCompatResizableIcon,
      IDS_ARC_COMPAT_MODE_RESIZE_TOGGLE_MENU_RESIZABLE);

  UpdateSelectedButton();

  // We need to ensure that the layer is non-opaque for popup animation.
  delegate_view->SetPaintToLayer();
  delegate_view->layer()->SetFillsBoundsOpaquely(false);

  // Note this view needs to be set to paint to layer so other view won't
  // paint over it.
  container_view->SetPaintToLayer();
  container_view->layer()->SetFillsBoundsOpaquely(false);

  return delegate_view;
}

void ResizeToggleMenu::UpdateSelectedButton() {
  // No need to update the button states if the widget is (being) closed.
  if (!widget_)
    return;

  const auto selected_mode = ash::compat_mode_util::PredictCurrentMode(widget_);
  phone_button_->SetSelected(selected_mode == ash::ResizeCompatMode::kPhone);
  tablet_button_->SetSelected(selected_mode == ash::ResizeCompatMode::kTablet);
  resizable_button_->SetSelected(selected_mode ==
                                 ash::ResizeCompatMode::kResizable);
}

void ResizeToggleMenu::ApplyResizeCompatMode(ash::ResizeCompatMode mode) {
  switch (mode) {
    case ash::ResizeCompatMode::kPhone:
      ResizeLockToPhone(widget_, pref_delegate_);
      break;
    case ash::ResizeCompatMode::kTablet:
      ResizeLockToTablet(widget_, pref_delegate_);
      break;
    case ash::ResizeCompatMode::kResizable:
      EnableResizingWithConfirmationIfNeeded(widget_, pref_delegate_);
      break;
  }

  auto_close_closure_.Reset(base::BindOnce(&ResizeToggleMenu::CloseBubble,
                                           weak_ptr_factory_.GetWeakPtr()));
  constexpr auto kAutoCloseDelay = base::Seconds(2);
  base::SequencedTaskRunner::GetCurrentDefault()->PostDelayedTask(
      FROM_HERE, auto_close_closure_.callback(), kAutoCloseDelay);
}

bool ResizeToggleMenu::IsBubbleShown() const {
  return bubble_view_ && bubble_view_->GetWidget();
}

void ResizeToggleMenu::CloseBubble() {
  if (!bubble_widget_ || bubble_widget_->IsClosed())
    return;

  bubble_widget_->CloseWithReason(views::Widget::ClosedReason::kUnspecified);
}

}  // namespace arc