chromium/ash/style/system_dialog_delegate_view.cc

// 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/style/system_dialog_delegate_view.h"

#include <memory>

#include "ash/public/cpp/ash_view_ids.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/typography.h"
#include "base/functional/bind.h"
#include "ui/aura/window.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/color/color_id.h"
#include "ui/compositor/layer.h"
#include "ui/events/event.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/highlight_border.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/flex_layout_types.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/layout/layout_types.h"
#include "ui/views/view_class_properties.h"
#include "ui/wm/core/window_util.h"

namespace ash {

namespace {

// The default color IDs of the dialog.
constexpr ui::ColorId kBackgroundColorId = cros_tokens::kCrosSysBaseElevated;
constexpr ui::ColorId kBodyColorId = cros_tokens::kCrosSysOnSurfaceVariant;
constexpr ui::ColorId kIconColorId = cros_tokens::kCrosSysPrimary;
constexpr ui::ColorId kTitleColorId = cros_tokens::kCrosSysOnSurface;

// The default layout parameters of the dialog.
constexpr gfx::Size kMinimumDialogSize = gfx::Size(296, 144);
constexpr gfx::Size kMaximumDialogSize = gfx::Size(512, 600);
constexpr int kRoundedCornerRadius = 20;
constexpr gfx::Insets kBorderInsets = gfx::Insets::TLBR(32, 32, 28, 32);
constexpr int kIconSize = 32;
constexpr int kIconBottomPadding = 20;
constexpr int kTitleBottomPadding = 16;
constexpr int kDefaultContentPadding = 32;
constexpr int kButtonContainerTopPadding = 32;
constexpr int kButtonSpacing = 8;
constexpr int kMinimumAdditionalButtonPadding = 80;

// Typical sizes of a dialog.
constexpr int kDialogWidthLarge = 512;
constexpr int kDialogWidthMedium = 359;
constexpr int kDialogWidthSmall = 296;

// The host window sizes that will change the resizing rule of the dialog.
constexpr int kHostWidthLarge = 672;
constexpr int kHostWidthMedium = 520;
constexpr int kHostWidthSmall = 424;
constexpr int kHostWidthXSmall = 400;

// Padding between the dialog and the host window.
constexpr int kDialogHostPaddingLarge = 80;
constexpr int kDialogHostPaddingSmall = 32;

// The default fonts of the title and description.
constexpr TypographyToken kTitleFont = TypographyToken::kCrosDisplay7;
constexpr TypographyToken kBodyFont = TypographyToken::kCrosBody1;

// Sets margins and flex layout specs to the view.
void SetViewLayoutSpecs(
    views::View* view,
    const gfx::Insets& margins = gfx::Insets(),
    const views::FlexSpecification flex_spec = views::FlexSpecification()) {
  view->SetProperty(views::kMarginsKey, margins);
  view->SetProperty(views::kFlexBehaviorKey, flex_spec);
}

// Sets the cross alignment to the given view.
void SetViewCrossAxisAlignment(views::View* view,
                               views::LayoutAlignment alignment) {
  CHECK(view);
  auto* cross_alignment = view->GetProperty(views::kCrossAxisAlignmentKey);
  if (!cross_alignment || *cross_alignment != alignment) {
    view->SetProperty(views::kCrossAxisAlignmentKey, alignment);
  }
}

// Gets the host window of the dialog.
aura::Window* GetDialogHostWindow(const views::Widget* dialog_widget) {
  if (!dialog_widget) {
    return nullptr;
  }

  // Return transient parent as the host window if exists. Otherwise, return the
  // default parent.
  auto* dialog_window = dialog_widget->GetNativeWindow();
  auto* transient_parent = wm::GetTransientParent(dialog_window);
  return transient_parent ? transient_parent : dialog_window->parent();
}

}  // namespace

DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemDialogDelegateView,
                                      kAcceptButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemDialogDelegateView,
                                      kCancelButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemDialogDelegateView,
                                      kDescriptionTextIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemDialogDelegateView,
                                      kTitleTextIdForTesting);

//------------------------------------------------------------------------------
// SystemDialogDelegateView::ButtonContainer:
// The container includes an accept button and a cancel button. The buttons are
// awlays at the right bottom corner of the dialog. The container also allows an
// additional view to be added at the left side. Please refer to the example in
// the header file for the container layout.
class SystemDialogDelegateView::ButtonContainer : public views::FlexLayoutView {
  METADATA_HEADER(ButtonContainer, views::FlexLayoutView)

 public:
  explicit ButtonContainer(SystemDialogDelegateView* dialog_view)
      : cancel_button_(AddChildView(std::make_unique<PillButton>(
            base::BindRepeating(&SystemDialogDelegateView::Cancel,
                                base::Unretained(dialog_view)),
            l10n_util::GetStringUTF16(IDS_APP_CANCEL),
            PillButton::Type::kSecondaryWithoutIcon))),
        accept_button_(AddChildView(std::make_unique<PillButton>(
            base::BindRepeating(&SystemDialogDelegateView::Accept,
                                base::Unretained(dialog_view)),
            l10n_util::GetStringUTF16(IDS_APP_OK),
            PillButton::Type::kPrimaryWithoutIcon))) {
    SetOrientation(views::LayoutOrientation::kHorizontal);
    SetMainAxisAlignment(views::LayoutAlignment::kEnd);
    SetCrossAxisAlignment(views::LayoutAlignment::kCenter);

    SetViewLayoutSpecs(cancel_button_,
                       gfx::Insets::TLBR(0, 0, 0, kButtonSpacing));

    cancel_button_->SetID(
        ViewID::VIEW_ID_STYLE_SYSTEM_DIALOG_DELEGATE_CANCEL_BUTTON);
    cancel_button_->SetProperty(views::kElementIdentifierKey,
                                kCancelButtonIdForTesting);
    cancel_button_->SetBackgroundColorId(cros_tokens::kCrosSysPrimaryContainer);
    cancel_button_->SetButtonTextColorId(
        cros_tokens::kCrosSysOnPrimaryContainer);
    cancel_button_->SetIconColorId(cros_tokens::kCrosSysOnPrimaryContainer);

    accept_button_->SetID(
        ViewID::VIEW_ID_STYLE_SYSTEM_DIALOG_DELEGATE_ACCEPT_BUTTON);
    accept_button_->SetProperty(views::kElementIdentifierKey,
                                kAcceptButtonIdForTesting);
    accept_button_->SetIsDefault(true);
  }

  ButtonContainer(const ButtonContainer&) = delete;
  ButtonContainer& operator=(const ButtonContainer&) = delete;
  ~ButtonContainer() override = default;

  const PillButton* accept_button() const { return accept_button_; }
  PillButton* accept_button() { return accept_button_; }
  const PillButton* cancel_button() const { return cancel_button_; }
  PillButton* cancel_button() { return cancel_button_; }

  void SetAcceptText(const std::u16string& accept_text) {
    accept_button_->SetText(accept_text);
  }

  void SetCancelText(const std::u16string& cancel_text) {
    cancel_button_->SetText(cancel_text);
  }

  void SetAdditionalView(std::unique_ptr<views::View> additional_view) {
    if (additional_view_) {
      RemoveChildViewT(additional_view_);
    }

    // Create a place holder view to fill the space between the cancel button
    // and the additional view.
    if (!place_holder_view_) {
      place_holder_view_ = AddChildViewAt(std::make_unique<views::View>(), 0);
      SetViewLayoutSpecs(
          place_holder_view_,
          gfx::Insets::TLBR(0, 0, 0, kMinimumAdditionalButtonPadding),
          views::FlexSpecification(views::MinimumFlexSizeRule::kPreferred,
                                   views::MaximumFlexSizeRule::kUnbounded));
    }
    additional_view_ = AddChildViewAt(std::move(additional_view), 0);
  }

 private:
  // Owned by the container.
  raw_ptr<PillButton> cancel_button_ = nullptr;
  raw_ptr<PillButton> accept_button_ = nullptr;
  raw_ptr<views::View> additional_view_ = nullptr;
  // The view used to fill the free spaces between the additional view and
  // cancel button.
  raw_ptr<views::View> place_holder_view_ = nullptr;
};

BEGIN_METADATA(SystemDialogDelegateView, ButtonContainer)
END_METADATA

//------------------------------------------------------------------------------
// SystemDialogDelegateView:
SystemDialogDelegateView::SystemDialogDelegateView() {
  // Set border and background.
  SetBorder(views::CreatePaddedBorder(
      std::make_unique<views::HighlightBorder>(
          kRoundedCornerRadius,
          views::HighlightBorder::Type::kHighlightBorderOnShadow),
      kBorderInsets));
  SetBackground(views::CreateThemedRoundedRectBackground(kBackgroundColorId,
                                                         kRoundedCornerRadius));

  // Set shadow.
  shadow_ = SystemShadow::CreateShadowOnNinePatchLayerForView(
      this, SystemShadow::Type::kElevation12);
  shadow_->SetRoundedCornerRadius(kRoundedCornerRadius);

  // Use flex layout.
  SetLayoutManager(std::make_unique<views::FlexLayout>())
      ->SetOrientation(views::LayoutOrientation::kVertical)
      .SetMainAxisAlignment(views::LayoutAlignment::kStart)
      .SetCrossAxisAlignment(views::LayoutAlignment::kStretch)
      .SetCollapseMargins(true);

  icon_ = AddChildView(std::make_unique<views::ImageView>());
  SetViewLayoutSpecs(icon_, gfx::Insets::TLBR(0, 0, kIconBottomPadding, 0));
  icon_->SetProperty(views::kCrossAxisAlignmentKey,
                     views::LayoutAlignment::kStart);
  icon_->SetVisible(false);

  // Configure icon, title, description, and button container with pre-defined
  // layout.
  auto* typography_provider = TypographyProvider::Get();
  title_ = AddChildView(std::make_unique<views::Label>());
  SetViewLayoutSpecs(title_, gfx::Insets::TLBR(0, 0, kTitleBottomPadding, 0));
  typography_provider->StyleLabel(kTitleFont, *title_);
  title_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
  title_->SetAutoColorReadabilityEnabled(false);
  title_->SetEnabledColorId(kTitleColorId);
  title_->SetVisible(false);
  title_->GetViewAccessibility().SetRole(ax::mojom::Role::kHeading);
  title_->SetProperty(views::kElementIdentifierKey, kTitleTextIdForTesting);

  description_ = AddChildView(std::make_unique<views::Label>());
  SetViewLayoutSpecs(
      description_, gfx::Insets(),
      views::FlexSpecification(views::LayoutOrientation::kHorizontal,
                               views::MinimumFlexSizeRule::kScaleToMinimum,
                               views::MaximumFlexSizeRule::kUnbounded, true,
                               views::MinimumFlexSizeRule::kPreferred));
  typography_provider->StyleLabel(kBodyFont, *description_);
  description_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
  description_->SetMultiLine(true);
  description_->SetAllowCharacterBreak(true);
  description_->SetAutoColorReadabilityEnabled(false);
  description_->SetEnabledColorId(kBodyColorId);
  description_->SetVisible(false);
  description_->SetProperty(views::kElementIdentifierKey,
                            kDescriptionTextIdForTesting);

  button_container_ = AddChildView(std::make_unique<ButtonContainer>(this));
  SetViewLayoutSpecs(
      button_container_, gfx::Insets::TLBR(kButtonContainerTopPadding, 0, 0, 0),
      views::FlexSpecification(views::LayoutOrientation::kHorizontal,
                               views::MinimumFlexSizeRule::kScaleToMinimum,
                               views::MaximumFlexSizeRule::kUnbounded));

  SetAccessibleWindowRole(ax::mojom::Role::kDialog);
  // Make dialog initially focus on the accept button.
  SetInitiallyFocusedView(button_container_->accept_button());

  // Register the close callback.
  RegisterWindowClosingCallback(
      base::BindOnce(&SystemDialogDelegateView::Close, base::Unretained(this)));
}

SystemDialogDelegateView::~SystemDialogDelegateView() = default;

void SystemDialogDelegateView::SetIcon(const gfx::VectorIcon& icon) {
  icon_->SetImage(
      ui::ImageModel::FromVectorIcon(icon, kIconColorId, kIconSize));
  icon_->SetVisible(true);
}

void SystemDialogDelegateView::SetTitleText(const std::u16string& title) {
  title_->SetText(title);
  title_->SetVisible(!title.empty());
  SetAccessibleTitle(title);
}

void SystemDialogDelegateView::SetDescription(
    const std::u16string& description) {
  description_->SetText(description);
  description_->SetVisible(!description.empty());
}

void SystemDialogDelegateView::SetDescriptionAccessibleName(
    const std::u16string& accessible_name) {
  description_->GetViewAccessibility().SetName(accessible_name);
}

void SystemDialogDelegateView::SetAcceptButtonVisible(bool visible) {
  button_container_->accept_button()->SetVisible(visible);
}

void SystemDialogDelegateView::SetCancelButtonVisible(bool visible) {
  button_container_->cancel_button()->SetVisible(visible);
}

void SystemDialogDelegateView::SetAcceptButtonText(
    const std::u16string& accept_text) {
  button_container_->SetAcceptText(accept_text);
}

void SystemDialogDelegateView::SetCancelButtonText(
    const std::u16string& cancel_text) {
  button_container_->SetCancelText(cancel_text);
}

void SystemDialogDelegateView::SetButtonContainerAlignment(
    views::LayoutAlignment alignment) {
  button_container_->SetMainAxisAlignment(alignment);
}

void SystemDialogDelegateView::SetTopContentAlignment(
    views::LayoutAlignment alignment) {
  SetViewCrossAxisAlignment(contents_[ContentType::kTop], alignment);
}

void SystemDialogDelegateView::SetMiddleContentAlignment(
    views::LayoutAlignment alignment) {
  SetViewCrossAxisAlignment(contents_[ContentType::kMiddle], alignment);
}

void SystemDialogDelegateView::SetTitleMargins(const gfx::Insets& margins) {
  SetViewLayoutSpecs(title_, margins);
}

gfx::Size SystemDialogDelegateView::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  auto* host_window = GetDialogHostWindow(GetWidget());
  // If the delegate view is not added to a widget or parented to a host window,
  // return the default preferred size.
  if (!host_window) {
    return views::WidgetDelegateView::CalculatePreferredSize(available_size);
  }

  // Otherwise, calculate the preferred size according to its host window size.
  const int host_width = host_window->GetBoundsInScreen().width();
  // The resizing rules of the dialog are as follows:
  // - When the host window width is larger than `kHostWidthLarge`, the dialog
  // width would remain at `kDialogWidthLarge`.
  // - When the host window width is between `kHostWidthMedium` and
  // `kHostWidthLarge`, the dialog width will decrease but maintain a padding
  // of `kDialogHostPaddingLarge` on both sides.
  // - When the host window width is between `kHostWidthSmall` and
  // `kHostWidthMedium`, the dialog width would remain at `kDialogWidthMedium`.
  // - When the host window width is less than `kHostWidthXSmall`, the dialog
  // width will decrease but maintain a padding of `kDialogHostPaddingSmall` on
  // both sides.
  // - The dialog minimum width is `kDialogWidthSmall`.
  int dialog_width = kDialogWidthSmall;
  if (host_width >= kHostWidthLarge) {
    dialog_width = kDialogWidthLarge;
  } else if (host_width >= kHostWidthMedium) {
    dialog_width = host_width - kDialogHostPaddingLarge * 2;
  } else if (host_width >= kHostWidthSmall) {
    dialog_width = kDialogWidthMedium;
  } else if (host_width >= kHostWidthXSmall) {
    dialog_width = host_width - kDialogHostPaddingSmall * 2;
  }

  return gfx::Size(dialog_width, GetLayoutManager()->GetPreferredHeightForWidth(
                                     this, dialog_width));
}

gfx::Size SystemDialogDelegateView::GetMinimumSize() const {
  return kMinimumDialogSize;
}

gfx::Size SystemDialogDelegateView::GetMaximumSize() const {
  return kMaximumDialogSize;
}

void SystemDialogDelegateView::OnWidgetInitialized() {
  UpdateDialogSize();
}

void SystemDialogDelegateView::OnWorkAreaChanged() {
  UpdateDialogSize();
}

const PillButton* SystemDialogDelegateView::GetAcceptButtonForTesting() const {
  return button_container_->accept_button();
}

const PillButton* SystemDialogDelegateView::GetCancelButtonForTesting() const {
  return button_container_->cancel_button();
}

void SystemDialogDelegateView::UpdateDialogSize() {
  if (auto* widget = GetWidget()) {
    widget->CenterWindow(GetPreferredSize());
  }
}

size_t SystemDialogDelegateView::GetContentIndex(ContentType type) const {
  switch (type) {
    case ContentType::kTop:
      return 0u;
    case ContentType::kMiddle:
      // The middle content is right after the description.
      return GetIndexOf(description_).value() + 1u;
  }
}

void SystemDialogDelegateView::SetContentInternal(
    std::unique_ptr<views::View> view,
    ContentType type) {
  // If there is an existing content, remove it.
  views::View* content = contents_[type];
  if (content) {
    contents_[type] = nullptr;
    RemoveChildViewT(content);
  }

  // Add content and move it to the specific position.
  content = AddChildViewAt(std::move(view), GetContentIndex(type));

  // Set default bottom/top margins to the top/middle content if there is no
  // preset margins or the corresponding margins are 0.
  auto* margins = content->GetProperty(views::kMarginsKey);
  switch (type) {
    case ContentType::kTop:
      if (!margins) {
        content->SetProperty(
            views::kMarginsKey,
            gfx::Insets::TLBR(0, 0, kDefaultContentPadding, 0));
      } else if (!margins->bottom()) {
        margins->set_bottom(kDefaultContentPadding);
      }
      break;
    case ContentType::kMiddle:
      if (!margins) {
        content->SetProperty(
            views::kMarginsKey,
            gfx::Insets::TLBR(kDefaultContentPadding, 0, 0, 0));
      } else if (!margins->top()) {
        margins->set_top(kDefaultContentPadding);
      }
      break;
  }
  content->SetProperty(views::kCrossAxisAlignmentKey,
                       views::LayoutAlignment::kCenter);
  contents_[type] = content;
}

void SystemDialogDelegateView::SetAdditionalViewInButtonRowInternal(
    std::unique_ptr<views::View> view) {
  button_container_->SetAdditionalView(std::move(view));
}

void SystemDialogDelegateView::Accept() {
  if (!closing_dialog_) {
    RunCallbackAndCloseDialog(
        std::move(accept_callback_),
        views::Widget::ClosedReason::kAcceptButtonClicked);
  }
}

void SystemDialogDelegateView::Cancel() {
  if (!closing_dialog_) {
    RunCallbackAndCloseDialog(
        std::move(cancel_callback_),
        views::Widget::ClosedReason::kCancelButtonClicked);
  }
}

void SystemDialogDelegateView::Close() {
  if (!closing_dialog_ && close_callback_) {
    std::move(close_callback_).Run();
  }
  closing_dialog_ = true;
}

void SystemDialogDelegateView::RunCallbackAndCloseDialog(
    base::OnceClosure callback,
    views::Widget::ClosedReason closed_reason) {
  CHECK(!closing_dialog_);

  if (callback) {
    std::move(callback).Run();
  }

  if (auto* widget = GetWidget()) {
    // Update the `closing_dialog_` before closing the widget.
    closing_dialog_ = true;
    widget->CloseWithReason(closed_reason);
  }
}

BEGIN_METADATA(SystemDialogDelegateView)
END_METADATA

}  // namespace ash