blob: a3ce92fc9f3be22497ba0c312c38cded04af5631 [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ui/toasts/toast_view.h"
#include <algorithm>
#include <climits>
#include <memory>
#include <utility>
#include "base/functional/bind.h"
#include "base/functional/callback_forward.h"
#include "chrome/app/vector_icons/vector_icons.h"
#include "chrome/browser/ui/browser_element_identifiers.h"
#include "chrome/browser/ui/views/chrome_layout_provider.h"
#include "chrome/browser/ui/views/chrome_typography.h"
#include "chrome/grit/generated_resources.h"
#include "components/strings/grit/components_strings.h"
#include "components/vector_icons/vector_icons.h"
#include "ui/base/accelerators/accelerator.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/menu_model.h"
#include "ui/base/mojom/menu_source_type.mojom.h"
#include "ui/compositor/layer.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/button/image_button_factory.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/menu/menu_item_view.h"
#include "ui/views/controls/menu/menu_model_adapter.h"
#include "ui/views/controls/menu/menu_runner.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/flex_layout_types.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/widget/widget.h"
#include "ui/views/window/dialog_client_view.h"
#include "ui/views/window/dialog_delegate.h"
namespace {
constexpr int kAnimationEntryDuration = 300;
constexpr int kAnimationExitDuration = 150;
constexpr int kAnimationHeightOffset = 50;
constexpr float kAnimationHeightScale = 0.5;
gfx::Transform GetScaleTransformation(gfx::Rect bounds) {
gfx::Transform transform;
transform.Translate(0,
bounds.CenterPoint().y() * (1 - kAnimationHeightScale));
transform.Scale(1, kAnimationHeightScale);
return transform;
}
bool IsCompatibleImageSize(const ui::ImageModel* image) {
const auto intended_size = toasts::ToastView::GetIconSize();
const auto image_size = image->Size();
return image_size.width() == intended_size &&
image_size.height() == intended_size;
}
gfx::Insets GetLeftMargin(const int left_margin) {
return gfx::Insets::TLBR(0, left_margin, 0, 0);
}
// A simple `MenuModelAdapter` that runs `on_executed_command` whenever the
// command corresponding to a menu item is executed.
class ToastMenuModelAdapter : public views::MenuModelAdapter {
public:
ToastMenuModelAdapter(ui::MenuModel* menu_model,
base::RepeatingClosure on_menu_closed,
base::RepeatingClosure on_executed_command)
: views::MenuModelAdapter(menu_model, std::move(on_menu_closed)),
on_executed_command_(std::move(on_executed_command)) {}
~ToastMenuModelAdapter() override = default;
private:
// views::MenuModelAdapter:
void ExecuteCommand(int id) override {
views::MenuModelAdapter::ExecuteCommand(id);
on_executed_command_.Run();
}
void ExecuteCommand(int id, int mouse_event_flags) override {
views::MenuModelAdapter::ExecuteCommand(id, mouse_event_flags);
on_executed_command_.Run();
}
base::RepeatingClosure on_executed_command_;
};
} // namespace
namespace toasts {
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(ToastView, kToastViewId);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(ToastView, kToastActionButton);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(ToastView, kToastCloseButton);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(ToastView, kToastMenuButton);
ToastView::ToastView(
views::View* anchor_view,
const std::u16string& toast_text,
const gfx::VectorIcon& icon,
const ui::ImageModel* image_override,
bool render_toast_over_web_contents,
base::RepeatingCallback<void(ToastCloseReason)> toast_close_callback)
: BubbleDialogDelegateView(anchor_view, views::BubbleBorder::NONE),
AnimationDelegateViews(this),
toast_text_(toast_text),
icon_(icon),
image_override_(image_override),
render_toast_over_web_contents_(render_toast_over_web_contents),
toast_close_callback_(std::move(toast_close_callback)) {
SetBackgroundColor(ui::kColorToastBackgroundProminent);
SetShowCloseButton(false);
DialogDelegate::SetButtons(static_cast<int>(ui::mojom::DialogButton::kNone));
set_corner_radius(ChromeLayoutProvider::Get()->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_HEIGHT));
SetProperty(views::kElementIdentifierKey, kToastElementId);
set_close_on_deactivate(false);
SetProperty(views::kElementIdentifierKey, kToastViewId);
SetAccessibleWindowRole(ax::mojom::Role::kAlert);
SetAccessibleTitle(toast_text_);
}
ToastView::~ToastView() = default;
void ToastView::AddActionButton(const std::u16string& action_button_text,
base::RepeatingClosure action_button_callback) {
CHECK(!has_action_button_);
has_action_button_ = true;
action_button_text_ = action_button_text;
action_button_callback_ = std::move(action_button_callback);
}
void ToastView::AddCloseButton(base::RepeatingClosure close_callback) {
CHECK(!has_close_button_);
has_close_button_ = true;
close_button_callback_ = std::move(close_callback);
}
void ToastView::AddMenu(std::unique_ptr<ui::MenuModel> model) {
CHECK(!menu_model_);
menu_model_ = std::move(model);
menu_model_adapter_ = std::make_unique<ToastMenuModelAdapter>(
menu_model_.get(),
/*on_menu_closed=*/
base::BindRepeating(&ToastView::OnMenuClosed, base::Unretained(this)),
/*on_executed_command=*/
base::BindRepeating(&ToastView::Close, base::Unretained(this),
ToastCloseReason::kMenuItemClick));
}
int ToastView::GetIconSize() {
const ChromeLayoutProvider* lp = ChromeLayoutProvider::Get();
return lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_ICON_SIZE);
}
void ToastView::Init() {
ChromeLayoutProvider* lp = ChromeLayoutProvider::Get();
// FlexLayout lets the toast compress itself in narrow browser windows.
SetLayoutManager(std::make_unique<views::FlexLayout>())
->SetOrientation(views::LayoutOrientation::kHorizontal);
icon_view_ = AddChildView(std::make_unique<views::ImageView>());
icon_view_->SetProperty(
views::kMarginsKey,
gfx::Insets::VH(0, lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_LEADING_ICON_SIDE_MARGINS)));
label_ = AddChildView(
std::make_unique<views::Label>(toast_text_, CONTEXT_TOAST_BODY_TEXT));
label_->SetEnabledColor(ui::kColorToastForeground);
label_->SetMultiLine(false);
label_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
label_->SetAllowCharacterBreak(false);
label_->SetAutoColorReadabilityEnabled(false);
label_->SetLineHeight(
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT_CONTENT));
label_->SetProperty(views::kMarginsKey,
GetLeftMargin(lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_BETWEEN_CHILD_SPACING)));
label_->SetProperty(
views::kFlexBehaviorKey,
views::FlexSpecification(views::LayoutOrientation::kHorizontal,
views::MinimumFlexSizeRule::kScaleToZero));
int max_child_height =
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT_CONTENT);
if (has_action_button_) {
action_button_ = AddChildView(std::make_unique<views::MdTextButton>(
action_button_callback_.Then(
base::BindRepeating(&ToastView::Close, base::Unretained(this),
ToastCloseReason::kActionButton)),
action_button_text_));
action_button_->SetEnabledTextColors(ui::kColorToastButton);
action_button_->SetBgColorIdOverride(ui::kColorToastBackgroundProminent);
action_button_->SetStrokeColorIdOverride(ui::kColorToastButton);
action_button_->SetPreferredSize(gfx::Size(
action_button_->GetPreferredSize().width(),
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT_ACTION_BUTTON)));
action_button_->SetStyle(ui::ButtonStyle::kProminent);
action_button_->GetViewAccessibility().SetRole(ax::mojom::Role::kAlert);
action_button_->SetProperty(views::kElementIdentifierKey,
kToastActionButton);
action_button_->SetAppearDisabledInInactiveWidget(false);
action_button_->SetProperty(
views::kMarginsKey,
GetLeftMargin(lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_BETWEEN_LABEL_ACTION_BUTTON_SPACING)));
SetInitiallyFocusedView(action_button_);
max_child_height = std::max(
max_child_height,
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT_ACTION_BUTTON));
}
if (has_close_button_) {
close_button_ = AddChildView(views::CreateVectorImageButtonWithNativeTheme(
close_button_callback_.Then(
base::BindRepeating(&ToastView::Close, base::Unretained(this),
ToastCloseReason::kCloseButton)),
vector_icons::kCloseChromeRefreshIcon,
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_ICON_SIZE),
ui::kColorToastForeground));
// Override the image button's border with the appropriate icon border size.
const gfx::Insets insets =
lp->GetInsetsMetric(views::InsetsMetric::INSETS_VECTOR_IMAGE_BUTTON);
close_button_->SetBorder(views::CreateEmptyBorder(insets));
views::InstallCircleHighlightPathGenerator(close_button_);
close_button_->SetAccessibleName(l10n_util::GetStringUTF16(IDS_CLOSE));
close_button_->SetProperty(views::kElementIdentifierKey, kToastCloseButton);
close_button_->SetProperty(
views::kMarginsKey, GetLeftMargin(lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_BETWEEN_CHILD_SPACING)));
close_button_->SetTooltipText(
l10n_util::GetStringUTF16(IDS_TOAST_CLOSE_TOOLTIP));
if (!HasConfiguredInitiallyFocusedView()) {
SetInitiallyFocusedView(close_button_);
}
max_child_height =
std::max(max_child_height,
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_ICON_SIZE) +
insets.height());
}
if (menu_model_) {
menu_button_ = AddChildView(views::CreateVectorImageButtonWithNativeTheme(
base::RepeatingClosure(), kBrowserToolsChromeRefreshIcon,
/*dip_size=*/
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_MENU_ICON_SIZE),
ui::kColorToastForeground));
views::InstallCircleHighlightPathGenerator(menu_button_);
menu_button_->SetProperty(views::kElementIdentifierKey, kToastMenuButton);
menu_button_->SetButtonController(
std::make_unique<views::MenuButtonController>(
menu_button_,
base::BindRepeating(&ToastView::OnMenuButtonClicked,
base::Unretained(this)),
std::make_unique<views::Button::DefaultButtonControllerDelegate>(
menu_button_)));
menu_button_->SetAccessibleName(
l10n_util::GetStringUTF16(IDS_TOAST_MENU_BUTTON_NAME));
const gfx::Insets insets = menu_button_->GetInsets();
const int left_margin =
lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_BETWEEN_LABEL_MENU_BUTTON_SPACING) -
insets.left();
menu_button_->SetProperty(views::kMarginsKey, GetLeftMargin(left_margin));
if (!HasConfiguredInitiallyFocusedView()) {
SetInitiallyFocusedView(menu_button_);
}
max_child_height =
std::max(max_child_height,
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_MENU_ICON_SIZE) +
insets.height());
}
// Height of the toast is set implicitly by adding margins depending on the
// height of the tallest child. We cannot simply iterate over all children
// here because the `ImageModel` for `ImageButton`s is only set once the theme
// is available.
const int total_vertical_margins =
lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT) - max_child_height;
const int top_margin = total_vertical_margins / 2;
const int right_margin = [&]() {
if (menu_button_) {
return lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_MARGIN_RIGHT_CLOSE_BUTTON);
} else if (close_button_) {
return lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_MARGIN_RIGHT_CLOSE_BUTTON);
} else if (action_button_) {
return lp->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_MARGIN_RIGHT_ACTION_BUTTON);
}
return lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_MARGIN_RIGHT_LABEL);
}();
set_margins(gfx::Insets::TLBR(
top_margin, lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_MARGIN_LEFT),
total_vertical_margins - top_margin, right_margin));
if (has_action_button_ || has_close_button_ || menu_model_) {
SetFocusTraversesOut(true);
} else {
set_focus_traversable_from_anchor_view(false);
SetCanActivate(false);
}
}
void ToastView::AnimationProgressed(const gfx::Animation* animation) {
const double value = gfx::Tween::CalculateValue(
height_animation_tween_, height_animation_.GetCurrentValue());
const gfx::Rect current_bounds = gfx::Tween::RectValueBetween(
value, starting_widget_bounds_, target_widget_bounds_);
GetWidget()->SetBounds(current_bounds);
}
void ToastView::AnimateIn() {
if (!gfx::Animation::ShouldRenderRichAnimation()) {
return;
}
target_widget_bounds_ = GetWidget()->GetWindowBoundsInScreen();
starting_widget_bounds_ =
target_widget_bounds_ - gfx::Vector2d{0, kAnimationHeightOffset};
height_animation_tween_ = gfx::Tween::ACCEL_5_70_DECEL_90;
height_animation_.SetDuration(base::Milliseconds(kAnimationEntryDuration));
height_animation_.Start();
views::View* const bubble_frame_view = GetBubbleFrameView();
bubble_frame_view->SetPaintToLayer();
bubble_frame_view->layer()->SetFillsBoundsOpaquely(false);
bubble_frame_view->SetTransform(
GetScaleTransformation(bubble_frame_view->bounds()));
bubble_frame_view->layer()->SetOpacity(0);
GetDialogClientView()->SetBackground(
views::CreateSolidBackground(ui::kColorToastBackgroundProminent));
GetDialogClientView()->layer()->SetOpacity(0);
views::AnimationBuilder()
.Once()
.SetDuration(base::Milliseconds(kAnimationEntryDuration))
.SetTransform(bubble_frame_view, gfx::Transform(),
height_animation_tween_)
.At(base::TimeDelta())
.SetDuration(base::Milliseconds(50))
.SetOpacity(bubble_frame_view, 1)
.Then()
.SetDuration(base::Milliseconds(150))
.SetOpacity(GetDialogClientView(), 1);
}
void ToastView::Close(ToastCloseReason reason) {
// Do not close if the menu is open - instead, remember the call to close it.
if (menu_runner_) {
pending_close_reason_ = reason;
return;
}
// TODO(crbug.com/358610872): Log toast close reason metric.
views::Widget::ClosedReason widget_closed_reason =
views::Widget::ClosedReason::kUnspecified;
switch (reason) {
case ToastCloseReason::kCloseButton:
widget_closed_reason = views::Widget::ClosedReason::kCloseButtonClicked;
break;
case ToastCloseReason::kActionButton:
widget_closed_reason = views::Widget::ClosedReason::kAcceptButtonClicked;
break;
default:
break;
}
toast_close_callback_.Run(reason);
if (GetWidget()->IsVisible()) {
AnimateOut(
base::BindOnce(&views::Widget::CloseWithReason,
base::Unretained(GetWidget()), widget_closed_reason),
reason != ToastCloseReason::kPreempted);
} else {
GetWidget()->CloseWithReason(widget_closed_reason);
}
}
void ToastView::UpdateRenderToastOverWebContentsAndPaint(
const bool render_toast_over_web_contents) {
render_toast_over_web_contents_ = render_toast_over_web_contents;
SizeToContents();
}
gfx::Rect ToastView::GetBubbleBounds() {
views::View* anchor_view = GetAnchorView();
if (!anchor_view) {
return gfx::Rect();
}
const gfx::Size preferred_size =
GetWidget()->GetContentsView()->GetPreferredSize();
const gfx::Rect anchor_bounds = anchor_view->GetBoundsInScreen();
// A wide toast in a narrow browser window needs to be compressed to fit.
const int minimum_margin = ChromeLayoutProvider::Get()->GetDistanceMetric(
DISTANCE_TOAST_BUBBLE_BROWSER_WINDOW_MARGIN) -
views::BubbleBorder::kShadowBlur;
const int width =
std::min(preferred_size.width(),
std::max(anchor_bounds.width() - 2 * minimum_margin, 0));
const int x = anchor_bounds.x() + ((anchor_bounds.width() - width) / 2);
// Take bubble out of its original bounds to cross "line of death", unless in
// fullscreen mode where the top container isn't rendered.
const int y = anchor_bounds.bottom() - (render_toast_over_web_contents_
? views::BubbleBorder::kShadowBlur
: (preferred_size.height() / 2));
return gfx::Rect(x, y, width, preferred_size.height());
}
void ToastView::OnThemeChanged() {
BubbleDialogDelegateView::OnThemeChanged();
const auto* color_provider = GetColorProvider();
if (image_override_ != nullptr && IsCompatibleImageSize(image_override_)) {
icon_view_->SetImage(*image_override_);
} else {
icon_view_->SetImage(ui::ImageModel::FromVectorIcon(
*icon_, color_provider->GetColor(ui::kColorToastForeground),
GetIconSize()));
}
}
void ToastView::AnimateOut(base::OnceClosure callback,
bool show_height_animation) {
if (!gfx::Animation::ShouldRenderRichAnimation()) {
std::move(callback).Run();
return;
}
views::View* const bubble_frame_view = GetBubbleFrameView();
if (!bubble_frame_view->layer()) {
bubble_frame_view->SetPaintToLayer();
bubble_frame_view->layer()->SetFillsBoundsOpaquely(false);
}
if (show_height_animation) {
starting_widget_bounds_ = GetWidget()->GetWindowBoundsInScreen();
target_widget_bounds_ =
starting_widget_bounds_ - gfx::Vector2d{0, kAnimationHeightOffset};
height_animation_tween_ = gfx::Tween::ACCEL_30_DECEL_20_85;
height_animation_.SetDuration(base::Milliseconds(kAnimationExitDuration));
height_animation_.Start();
views::AnimationBuilder()
.Once()
.SetDuration(base::Milliseconds(kAnimationExitDuration))
.SetTransform(bubble_frame_view,
GetScaleTransformation(bubble_frame_view->bounds()),
height_animation_tween_);
}
views::AnimationBuilder()
.OnEnded(std::move(callback))
.Once()
.SetDuration(base::Milliseconds(100))
.SetOpacity(GetDialogClientView(), 0)
.Then()
.SetDuration(base::Milliseconds(50))
.SetOpacity(bubble_frame_view, 0);
}
void ToastView::OnMenuButtonClicked() {
menu_runner_ = std::make_unique<views::MenuRunner>(
menu_model_adapter_->CreateMenu(), views::MenuRunner::FIXED_ANCHOR);
menu_runner_->RunMenuAt(GetWidget(),
static_cast<views::MenuButtonController*>(
menu_button_->button_controller()),
menu_button_->GetBoundsInScreen(),
views::MenuAnchorPosition::kTopRight,
ui::mojom::MenuSourceType::kNone);
}
void ToastView::OnMenuClosed() {
menu_runner_.reset();
if (pending_close_reason_) {
Close(pending_close_reason_.value());
}
}
BEGIN_METADATA(ToastView)
END_METADATA
} // namespace toasts