| // Copyright 2019 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/views/profiles/profile_menu_view_base.h" |
| |
| #include <algorithm> |
| #include <memory> |
| #include <utility> |
| |
| #include "base/feature_list.h" |
| #include "base/functional/bind.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/memory/raw_ref.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/scoped_observation.h" |
| #include "build/build_config.h" |
| #include "build/chromeos_buildflags.h" |
| #include "chrome/app/vector_icons/vector_icons.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/profiles/profile_avatar_icon_util.h" |
| #include "chrome/browser/signin/signin_ui_util.h" |
| #include "chrome/browser/themes/theme_properties.h" |
| #include "chrome/browser/ui/browser_window.h" |
| #include "chrome/browser/ui/chrome_pages.h" |
| #include "chrome/browser/ui/signin/profile_colors_util.h" |
| #include "chrome/browser/ui/ui_features.h" |
| #include "chrome/browser/ui/views/chrome_layout_provider.h" |
| #include "chrome/browser/ui/views/chrome_typography.h" |
| #include "chrome/browser/ui/views/controls/hover_button.h" |
| #include "chrome/grit/generated_resources.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_header_macros.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/base/models/image_model.h" |
| #include "ui/base/themed_vector_icon.h" |
| #include "ui/base/ui_base_features.h" |
| #include "ui/color/color_id.h" |
| #include "ui/color/color_provider.h" |
| #include "ui/display/display.h" |
| #include "ui/display/screen.h" |
| #include "ui/gfx/canvas.h" |
| #include "ui/gfx/image/canvas_image_source.h" |
| #include "ui/gfx/image/image_skia_operations.h" |
| #include "ui/gfx/vector_icon_types.h" |
| #include "ui/strings/grit/ui_strings.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/animation/ink_drop.h" |
| #include "ui/views/background.h" |
| #include "ui/views/border.h" |
| #include "ui/views/controls/button/label_button.h" |
| #include "ui/views/controls/button/md_text_button.h" |
| #include "ui/views/controls/highlight_path_generator.h" |
| #include "ui/views/controls/link.h" |
| #include "ui/views/controls/scroll_view.h" |
| #include "ui/views/controls/separator.h" |
| #include "ui/views/layout/fill_layout.h" |
| #include "ui/views/layout/flex_layout.h" |
| #include "ui/views/layout/flex_layout_types.h" |
| #include "ui/views/layout/table_layout.h" |
| #include "ui/views/view.h" |
| #include "ui/views/view_class_properties.h" |
| |
| namespace { |
| |
| // Helpers -------------------------------------------------------------------- |
| |
| constexpr int kMenuWidth = 288; |
| constexpr int kMaxImageSize = ProfileMenuViewBase::kIdentityImageSize; |
| constexpr int kDefaultMargin = 8; |
| constexpr int kBadgeSize = 16; |
| constexpr int kCircularImageButtonSize = 28; |
| constexpr int kCircularImageButtonRefreshSize = 32; |
| constexpr float kShortcutIconToImageRatio = 9.0f / 16.0f; |
| constexpr float kShortcutIconToImageRefreshRatio = 10.0f / 16.0f; |
| // TODO(crbug.com/1128499): Remove this constant by extracting art height from |
| // |avatar_header_art|. |
| constexpr int kHeaderArtHeight = 80; |
| constexpr int kIdentityImageBorder = 2; |
| constexpr int kIdentityImageSizeInclBorder = |
| ProfileMenuViewBase::kIdentityImageSize + 2 * kIdentityImageBorder; |
| constexpr int kHalfOfAvatarImageViewSize = kIdentityImageSizeInclBorder / 2; |
| |
| // If the bubble is too large to fit on the screen, it still needs to be at |
| // least this tall to show one row. |
| constexpr int kMinimumScrollableContentHeight = 40; |
| |
| // Spacing between the edge of the user menu and the top/bottom or left/right of |
| // the menu items. |
| constexpr int kMenuEdgeMargin = 16; |
| |
| constexpr int kSyncInfoInsidePadding = 12; |
| |
| gfx::ImageSkia SizeImage(const gfx::ImageSkia& image, int size) { |
| return gfx::ImageSkiaOperations::CreateResizedImage( |
| image, skia::ImageOperations::RESIZE_BEST, gfx::Size(size, size)); |
| } |
| |
| gfx::ImageSkia ColorImage(const gfx::ImageSkia& image, SkColor color) { |
| return gfx::ImageSkiaOperations::CreateColorMask(image, color); |
| } |
| |
| class CircleImageSource : public gfx::CanvasImageSource { |
| public: |
| CircleImageSource(int size, SkColor color) |
| : gfx::CanvasImageSource(gfx::Size(size, size)), color_(color) {} |
| |
| CircleImageSource(const CircleImageSource&) = delete; |
| CircleImageSource& operator=(const CircleImageSource&) = delete; |
| |
| ~CircleImageSource() override = default; |
| |
| void Draw(gfx::Canvas* canvas) override; |
| |
| private: |
| SkColor color_; |
| }; |
| |
| void CircleImageSource::Draw(gfx::Canvas* canvas) { |
| float radius = size().width() / 2.0f; |
| cc::PaintFlags flags; |
| flags.setStyle(cc::PaintFlags::kFill_Style); |
| flags.setAntiAlias(true); |
| flags.setColor(color_); |
| canvas->DrawCircle(gfx::PointF(radius, radius), radius, flags); |
| } |
| |
| gfx::ImageSkia CreateCircle(int size, SkColor color) { |
| return gfx::CanvasImageSource::MakeImageSkia<CircleImageSource>(size, color); |
| } |
| |
| gfx::ImageSkia CropCircle(const gfx::ImageSkia& image) { |
| DCHECK_EQ(image.width(), image.height()); |
| // The color here is irrelevant as long as it's opaque; only alpha matters. |
| return gfx::ImageSkiaOperations::CreateMaskedImage( |
| image, CreateCircle(image.width(), SK_ColorWHITE)); |
| } |
| |
| gfx::ImageSkia AddCircularBackground(const gfx::ImageSkia& image, |
| SkColor bg_color, |
| int size) { |
| if (image.isNull()) |
| return gfx::ImageSkia(); |
| |
| return gfx::ImageSkiaOperations::CreateSuperimposedImage( |
| CreateCircle(size, bg_color), image); |
| } |
| |
| std::unique_ptr<views::BoxLayout> CreateBoxLayout( |
| views::BoxLayout::Orientation orientation, |
| views::BoxLayout::CrossAxisAlignment cross_axis_alignment, |
| gfx::Insets insets = gfx::Insets()) { |
| auto layout = std::make_unique<views::BoxLayout>(orientation, insets); |
| layout->set_cross_axis_alignment(cross_axis_alignment); |
| return layout; |
| } |
| |
| const gfx::ImageSkia ImageForMenu(const gfx::VectorIcon& icon, |
| float icon_to_image_ratio, |
| SkColor color) { |
| const int padding = |
| static_cast<int>(kMaxImageSize * (1.0f - icon_to_image_ratio) / 2.0f); |
| |
| gfx::ImageSkia sized_icon = |
| gfx::CreateVectorIcon(icon, kMaxImageSize - 2 * padding, color); |
| return gfx::CanvasImageSource::CreatePadded(sized_icon, gfx::Insets(padding)); |
| } |
| |
| ui::ImageModel SizeImageModel(const ui::ImageModel& image_model, int size) { |
| DCHECK(!image_model.IsImageGenerator()); // Not prepared to handle these. |
| if (image_model.IsImage()) { |
| return ui::ImageModel::FromImageSkia( |
| CropCircle(SizeImage(image_model.GetImage().AsImageSkia(), size))); |
| } |
| const ui::VectorIconModel& model = image_model.GetVectorIcon(); |
| if (model.has_color()) { |
| return ui::ImageModel::FromVectorIcon(*model.vector_icon(), model.color(), |
| size); |
| } |
| return ui::ImageModel::FromVectorIcon(*model.vector_icon(), model.color_id(), |
| size); |
| } |
| |
| const gfx::ImageSkia ProfileManagementImageFromIcon( |
| const gfx::VectorIcon& icon, |
| const ui::ColorProvider* color_provider) { |
| constexpr float kIconToImageRatio = 0.75f; |
| constexpr int kIconSize = 20; |
| const SkColor icon_color = color_provider->GetColor(ui::kColorIcon); |
| gfx::ImageSkia image = ImageForMenu(icon, kIconToImageRatio, icon_color); |
| return SizeImage(image, kIconSize); |
| } |
| |
| // TODO(crbug.com/1146998): Adjust button size to be 16x16. |
| class CircularImageButton : public views::ImageButton { |
| public: |
| METADATA_HEADER(CircularImageButton); |
| |
| CircularImageButton(PressedCallback callback, |
| const gfx::VectorIcon& icon, |
| const std::u16string& text, |
| int button_size = kCircularImageButtonSize, |
| bool has_background_color = false, |
| bool show_border = false) |
| : ImageButton(std::move(callback)), |
| icon_(icon), |
| button_size_(button_size), |
| has_background_color_(has_background_color), |
| show_border_(show_border) { |
| SetTooltipText(text); |
| views::InkDrop::Get(this)->SetMode(views::InkDropHost::InkDropMode::ON); |
| |
| InstallCircleHighlightPathGenerator(this); |
| } |
| |
| // views::ImageButton: |
| void OnThemeChanged() override { |
| views::ImageButton::OnThemeChanged(); |
| const int kBorderThickness = show_border_ ? 1 : 0; |
| const SkScalar kButtonRadius = (button_size_ + 2 * kBorderThickness) / 2.0f; |
| |
| const auto* color_provider = GetColorProvider(); |
| SkColor icon_color = color_provider->GetColor(ui::kColorIcon); |
| if (features::IsChromeRefresh2023() && has_background_color_) { |
| // TODO(crbug.com/1422119): Set colors for hover and pressed states. |
| SkColor background_color = |
| color_provider->GetColor(ui::kColorSysTonalContainer); |
| icon_color = color_provider->GetColor(ui::kColorSysOnTonalContainer); |
| SetBackground( |
| views::CreateRoundedRectBackground(background_color, kButtonRadius)); |
| } |
| gfx::ImageSkia image = ImageForMenu(*icon_, |
| features::IsChromeRefresh2023() |
| ? kShortcutIconToImageRefreshRatio |
| : kShortcutIconToImageRatio, |
| icon_color); |
| SetImage(views::Button::STATE_NORMAL, SizeImage(image, button_size_)); |
| views::InkDrop::Get(this)->SetBaseColor(icon_color); |
| |
| // TODO(crbug.com/1422119): Remove border for Chrome Refresh 2023. |
| if (show_border_) { |
| const SkColor separator_color = |
| color_provider->GetColor(ui::kColorMenuSeparator); |
| SetBorder(views::CreateRoundedRectBorder(kBorderThickness, kButtonRadius, |
| separator_color)); |
| } |
| } |
| |
| private: |
| const raw_ref<const gfx::VectorIcon> icon_; |
| // In Chrome Refresh 2023, this kind of button could have different sizes in |
| // different sections of the Profile Menu, which is also different from the |
| // size in the previous version of the menu. |
| int button_size_; |
| // In Chrome Refresh 2023, some buttons on the Profile Menu have a background |
| // color that is based on the profile theme color and on light or dark mode, |
| // while other buttons have a transparent background. In the previous version |
| // of the menu, all backgrounds are transparent. |
| bool has_background_color_; |
| bool show_border_; |
| }; |
| |
| BEGIN_METADATA(CircularImageButton, views::ImageButton) |
| END_METADATA |
| |
| class FeatureButtonIconView : public views::ImageView { |
| public: |
| FeatureButtonIconView(const gfx::VectorIcon& icon, float icon_to_image_ratio) |
| : icon_(icon), icon_to_image_ratio_(icon_to_image_ratio) {} |
| ~FeatureButtonIconView() override = default; |
| |
| // views::ImageView: |
| void OnThemeChanged() override { |
| views::ImageView::OnThemeChanged(); |
| constexpr int kIconSize = 16; |
| const SkColor icon_color = GetColorProvider()->GetColor(ui::kColorIcon); |
| gfx::ImageSkia image = |
| ImageForMenu(*icon_, icon_to_image_ratio_, icon_color); |
| SetImage(SizeImage(ColorImage(image, icon_color), kIconSize)); |
| } |
| |
| private: |
| const raw_ref<const gfx::VectorIcon> icon_; |
| const float icon_to_image_ratio_; |
| }; |
| |
| class ProfileManagementFeatureButton : public HoverButton { |
| public: |
| METADATA_HEADER(ProfileManagementFeatureButton); |
| ProfileManagementFeatureButton(PressedCallback callback, |
| const gfx::VectorIcon& icon, |
| const std::u16string& clickable_text) |
| : HoverButton(std::move(callback), clickable_text), icon_(icon) {} |
| |
| // HoverButton: |
| void OnThemeChanged() override { |
| HoverButton::OnThemeChanged(); |
| SetImage(STATE_NORMAL, |
| ProfileManagementImageFromIcon(*icon_, GetColorProvider())); |
| } |
| |
| private: |
| const raw_ref<const gfx::VectorIcon> icon_; |
| }; |
| BEGIN_METADATA(ProfileManagementFeatureButton, HoverButton) |
| END_METADATA |
| |
| class ProfileManagementIconView : public views::ImageView { |
| public: |
| explicit ProfileManagementIconView(const gfx::VectorIcon& icon) |
| : icon_(icon) {} |
| ~ProfileManagementIconView() override = default; |
| |
| // views::ImageView: |
| void OnThemeChanged() override { |
| views::ImageView::OnThemeChanged(); |
| SetImage(ProfileManagementImageFromIcon(*icon_, GetColorProvider())); |
| } |
| |
| private: |
| const raw_ref<const gfx::VectorIcon> icon_; |
| }; |
| |
| // AvatarImageView is used to ensure avatar adornments are kept in sync with |
| // current theme colors. |
| class AvatarImageView : public views::ImageView { |
| public: |
| AvatarImageView(const ui::ImageModel& avatar_image, |
| const ProfileMenuViewBase* root_view) |
| : avatar_image_(avatar_image), root_view_(root_view) { |
| if (avatar_image_.IsEmpty()) { |
| // This can happen if the account image hasn't been fetched yet, if there |
| // is no image, or in tests. |
| avatar_image_ = ui::ImageModel::FromVectorIcon( |
| kUserAccountAvatarIcon, ui::kColorMenuIcon, |
| ProfileMenuViewBase::kIdentityImageSize); |
| } |
| } |
| |
| // views::ImageView: |
| void OnThemeChanged() override { |
| ImageView::OnThemeChanged(); |
| constexpr int kBadgePadding = 1; |
| DCHECK(!avatar_image_.IsEmpty()); |
| gfx::ImageSkia sized_avatar_image = |
| SizeImageModel(avatar_image_, ProfileMenuViewBase::kIdentityImageSize) |
| .Rasterize(GetColorProvider()); |
| sized_avatar_image = AddCircularBackground( |
| sized_avatar_image, GetBackgroundColor(), kIdentityImageSizeInclBorder); |
| gfx::ImageSkia sized_badge = AddCircularBackground( |
| SizeImage(root_view_->GetSyncIcon(), kBadgeSize), GetBackgroundColor(), |
| kBadgeSize + 2 * kBadgePadding); |
| gfx::ImageSkia sized_badge_with_shadow = |
| gfx::ImageSkiaOperations::CreateImageWithDropShadow( |
| sized_badge, gfx::ShadowValue::MakeMdShadowValues(/*elevation=*/1, |
| SK_ColorBLACK)); |
| |
| gfx::ImageSkia badged_image = gfx::ImageSkiaOperations::CreateIconWithBadge( |
| sized_avatar_image, sized_badge_with_shadow); |
| SetImage(badged_image); |
| } |
| |
| private: |
| SkColor GetBackgroundColor() const { |
| return GetColorProvider()->GetColor(ui::kColorBubbleBackground); |
| } |
| |
| ui::ImageModel avatar_image_; |
| raw_ptr<const ProfileMenuViewBase> root_view_; |
| }; |
| |
| class SyncButton : public HoverButton { |
| public: |
| METADATA_HEADER(SyncButton); |
| SyncButton(PressedCallback callback, |
| ProfileMenuViewBase* root_view, |
| const std::u16string& clickable_text) |
| : HoverButton(std::move(callback), clickable_text), |
| root_view_(root_view) {} |
| |
| // HoverButton: |
| void OnThemeChanged() override { |
| HoverButton::OnThemeChanged(); |
| SetImage(STATE_NORMAL, SizeImage(root_view_->GetSyncIcon(), kBadgeSize)); |
| } |
| |
| private: |
| raw_ptr<const ProfileMenuViewBase> root_view_; |
| }; |
| |
| BEGIN_METADATA(SyncButton, HoverButton) |
| END_METADATA |
| |
| class SyncImageView : public views::ImageView { |
| public: |
| explicit SyncImageView(const ProfileMenuViewBase* root_view) |
| : root_view_(root_view) {} |
| |
| // views::ImageView: |
| void OnThemeChanged() override { |
| ImageView::OnThemeChanged(); |
| SetImage(SizeImage(root_view_->GetSyncIcon(), kBadgeSize)); |
| } |
| |
| private: |
| raw_ptr<const ProfileMenuViewBase> root_view_; |
| }; |
| |
| void BuildProfileTitleAndSubtitle(views::View* parent, |
| const std::u16string& title, |
| const std::u16string& subtitle) { |
| views::View* profile_titles_container = |
| parent->AddChildView(std::make_unique<views::View>()); |
| // Separate the titles from the avatar image by the default margin. |
| profile_titles_container->SetLayoutManager( |
| CreateBoxLayout(views::BoxLayout::Orientation::kVertical, |
| views::BoxLayout::CrossAxisAlignment::kCenter, |
| gfx::Insets::TLBR(kDefaultMargin, 0, 0, 0))); |
| |
| if (!title.empty()) { |
| profile_titles_container->AddChildView(std::make_unique<views::Label>( |
| title, views::style::CONTEXT_DIALOG_TITLE)); |
| } |
| |
| if (!subtitle.empty()) { |
| profile_titles_container->AddChildView(std::make_unique<views::Label>( |
| subtitle, views::style::CONTEXT_LABEL, views::style::STYLE_SECONDARY)); |
| } |
| } |
| |
| // This function deals with the somewhat complicted layout to build the part of |
| // the profile identity info that has a colored background. |
| void BuildProfileBackgroundContainer( |
| views::View* parent, |
| std::unique_ptr<views::View> heading_label, |
| SkColor background_color, |
| std::unique_ptr<views::View> avatar_image_view, |
| std::unique_ptr<views::View> edit_button, |
| const ui::ThemedVectorIcon& avatar_header_art) { |
| views::View* profile_background_container = |
| parent->AddChildView(std::make_unique<views::View>()); |
| |
| auto background_container_insets = gfx::Insets::VH(0, kMenuEdgeMargin); |
| if (edit_button) { |
| // Compensate for the edit button on the right with an extra margin on the |
| // left so that the rest is centered. |
| background_container_insets.set_left(background_container_insets.left() + |
| kCircularImageButtonSize); |
| } |
| profile_background_container |
| ->SetLayoutManager(std::make_unique<views::FlexLayout>()) |
| ->SetOrientation(views::LayoutOrientation::kHorizontal) |
| .SetCrossAxisAlignment(views::LayoutAlignment::kEnd) |
| .SetInteriorMargin(background_container_insets); |
| |
| // Show a colored background iff there is no art. |
| if (avatar_header_art.empty()) { |
| // The bottom background edge should match the center of the identity image. |
| auto background_insets = |
| gfx::Insets::TLBR(0, 0, kHalfOfAvatarImageViewSize, 0); |
| // TODO(crbug.com/1147038): Remove the zero-radius rounded background. |
| profile_background_container->SetBackground( |
| views::CreateBackgroundFromPainter( |
| views::Painter::CreateSolidRoundRectPainter( |
| background_color, /*radius=*/0, background_insets))); |
| } else { |
| DCHECK_EQ(SK_ColorTRANSPARENT, background_color); |
| profile_background_container->SetBackground( |
| views::CreateThemedVectorIconBackground(avatar_header_art)); |
| } |
| |
| // |avatar_margin| is derived from |avatar_header_art| asset height, it |
| // increases margin for the avatar icon to make |avatar_header_art| visible |
| // above the center of the avatar icon. |
| const int avatar_margin = avatar_header_art.empty() |
| ? kMenuEdgeMargin |
| : kHeaderArtHeight - kHalfOfAvatarImageViewSize; |
| |
| // The |heading_and_image_container| is on the left and it stretches almost |
| // the full width. It contains the profile heading and the avatar image. |
| views::View* heading_and_image_container = |
| profile_background_container->AddChildView( |
| std::make_unique<views::View>()); |
| heading_and_image_container->SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero, |
| views::MaximumFlexSizeRule::kUnbounded) |
| .WithOrder(1)); |
| heading_and_image_container |
| ->SetLayoutManager(std::make_unique<views::FlexLayout>()) |
| ->SetOrientation(views::LayoutOrientation::kVertical) |
| .SetMainAxisAlignment(views::LayoutAlignment::kCenter) |
| .SetCrossAxisAlignment(views::LayoutAlignment::kCenter) |
| .SetInteriorMargin(gfx::Insets::TLBR(avatar_margin, 0, 0, 0)); |
| if (heading_label) { |
| DCHECK(avatar_header_art.empty()); |
| heading_label->SetBorder( |
| views::CreateEmptyBorder(gfx::Insets::VH(kDefaultMargin, 0))); |
| heading_and_image_container->AddChildView(std::move(heading_label)); |
| } |
| |
| heading_and_image_container->AddChildView(std::move(avatar_image_view)); |
| |
| // The |edit_button| is on the right and has fixed width. |
| if (edit_button) { |
| edit_button->SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::MinimumFlexSizeRule::kPreferred, |
| views::MaximumFlexSizeRule::kPreferred) |
| .WithOrder(2)); |
| views::View* edit_button_container = |
| profile_background_container->AddChildView( |
| std::make_unique<views::View>()); |
| edit_button_container->SetLayoutManager(CreateBoxLayout( |
| views::BoxLayout::Orientation::kVertical, |
| views::BoxLayout::CrossAxisAlignment::kCenter, |
| gfx::Insets::TLBR(0, 0, kHalfOfAvatarImageViewSize + kDefaultMargin, |
| 0))); |
| edit_button_container->AddChildView(std::move(edit_button)); |
| } |
| } |
| |
| } // namespace |
| |
| // ProfileMenuViewBase --------------------------------------------------------- |
| |
| ProfileMenuViewBase::EditButtonParams::EditButtonParams( |
| const gfx::VectorIcon* edit_icon, |
| const std::u16string& edit_tooltip_text, |
| base::RepeatingClosure edit_action) |
| : edit_icon(edit_icon), |
| edit_tooltip_text(edit_tooltip_text), |
| edit_action(edit_action) {} |
| |
| ProfileMenuViewBase::EditButtonParams::~EditButtonParams() = default; |
| |
| ProfileMenuViewBase::EditButtonParams::EditButtonParams( |
| const EditButtonParams&) = default; |
| |
| ProfileMenuViewBase::ProfileMenuViewBase(views::Button* anchor_button, |
| Browser* browser) |
| : BubbleDialogDelegateView(anchor_button, views::BubbleBorder::TOP_RIGHT), |
| browser_(browser), |
| anchor_button_(anchor_button), |
| close_bubble_helper_(this, browser) { |
| SetButtons(ui::DIALOG_BUTTON_NONE); |
| // TODO(tluk): Remove when fixing https://crbug.com/822075 |
| // The sign in webview will be clipped on the bottom corners without these |
| // margins, see related bug <http://crbug.com/593203>. |
| SetPaintClientToLayer(true); |
| set_margins(gfx::Insets(0)); |
| DCHECK(anchor_button); |
| views::InkDrop::Get(anchor_button) |
| ->AnimateToState(views::InkDropState::ACTIVATED, nullptr); |
| |
| SetEnableArrowKeyTraversal(true); |
| |
| // TODO(crbug.com/1341017): Using `SetAccessibleWindowRole(kMenu)` here will |
| // result in screenreader to announce the menu having only one item. This is |
| // probably because this API sets the a11y role for the widget, but not root |
| // view in it. This is confusing and prone to misuse. We should unify the two |
| // sets of API for BubbleDialogDelegateView. |
| GetViewAccessibility().OverrideRole(ax::mojom::Role::kMenu); |
| |
| RegisterWindowClosingCallback(base::BindOnce( |
| &ProfileMenuViewBase::OnWindowClosing, base::Unretained(this))); |
| } |
| |
| ProfileMenuViewBase::~ProfileMenuViewBase() = default; |
| |
| gfx::ImageSkia ProfileMenuViewBase::GetSyncIcon() const { |
| return gfx::ImageSkia(); |
| } |
| |
| void ProfileMenuViewBase::SetProfileIdentityInfo( |
| const std::u16string& profile_name, |
| SkColor profile_background_color, |
| absl::optional<EditButtonParams> edit_button_params, |
| const ui::ImageModel& image_model, |
| const std::u16string& title, |
| const std::u16string& subtitle, |
| const ui::ThemedVectorIcon& avatar_header_art) { |
| constexpr int kBottomMargin = kDefaultMargin; |
| |
| identity_info_container_->RemoveAllChildViews(); |
| // The colored background fully bleeds to the edges of the menu and to achieve |
| // that margin is set to 0. Further margins will be added by children views. |
| identity_info_container_->SetLayoutManager( |
| CreateBoxLayout(views::BoxLayout::Orientation::kVertical, |
| views::BoxLayout::CrossAxisAlignment::kStretch, |
| gfx::Insets::TLBR(0, 0, kBottomMargin, 0))); |
| |
| auto avatar_image_view = std::make_unique<AvatarImageView>(image_model, this); |
| |
| // TODO(crbug.com/1052397): Revisit once build flag switch of lacros-chrome is |
| // complete. |
| #if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_CHROMEOS_LACROS) |
| // crbug.com/1161166: Orca does not read the accessible window title of the |
| // bubble, so we duplicate it in the top-level menu item. To be revisited |
| // after considering other options, including fixes on the AT side. |
| GetViewAccessibility().OverrideName(GetAccessibleWindowTitle()); |
| #endif |
| |
| std::unique_ptr<views::Label> heading_label; |
| if (!profile_name.empty()) { |
| views::Label::CustomFont font = { |
| views::Label::GetDefaultFontList() |
| .DeriveWithSizeDelta(2) |
| .DeriveWithWeight(gfx::Font::Weight::BOLD)}; |
| heading_label = std::make_unique<views::Label>(profile_name, font); |
| heading_label->SetElideBehavior(gfx::ELIDE_TAIL); |
| heading_label->SetHorizontalAlignment(gfx::ALIGN_CENTER); |
| heading_label->SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero, |
| views::MaximumFlexSizeRule::kUnbounded)); |
| if (avatar_header_art.empty()) { |
| heading_label->SetAutoColorReadabilityEnabled(false); |
| heading_label->SetEnabledColor( |
| GetProfileForegroundTextColor(profile_background_color)); |
| } |
| } |
| |
| std::unique_ptr<views::View> edit_button; |
| if (edit_button_params.has_value()) { |
| edit_button = std::make_unique<CircularImageButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), |
| std::move(edit_button_params->edit_action)), |
| *edit_button_params->edit_icon, edit_button_params->edit_tooltip_text); |
| } |
| |
| BuildProfileBackgroundContainer( |
| /*parent=*/identity_info_container_, std::move(heading_label), |
| profile_background_color, std::move(avatar_image_view), |
| std::move(edit_button), avatar_header_art); |
| BuildProfileTitleAndSubtitle(/*parent=*/identity_info_container_, title, |
| subtitle); |
| } |
| |
| void ProfileMenuViewBase::BuildSyncInfoWithCallToAction( |
| const std::u16string& description, |
| const std::u16string& button_text, |
| ui::ColorId background_color_id, |
| const base::RepeatingClosure& action, |
| bool show_sync_badge) { |
| const int kDescriptionIconSpacing = |
| ChromeLayoutProvider::Get()->GetDistanceMetric( |
| views::DISTANCE_RELATED_LABEL_HORIZONTAL); |
| |
| sync_info_container_->RemoveAllChildViews(); |
| sync_info_container_->SetLayoutManager(std::make_unique<views::FlexLayout>()) |
| ->SetOrientation(views::LayoutOrientation::kVertical) |
| .SetIgnoreDefaultMainAxisMargins(true) |
| .SetCollapseMargins(true) |
| .SetDefault(views::kMarginsKey, |
| gfx::Insets::VH(kSyncInfoInsidePadding, 0)); |
| sync_info_container_->SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::LayoutOrientation::kVertical, |
| views::MinimumFlexSizeRule::kPreferred, |
| views::MaximumFlexSizeRule::kUnbounded, true, |
| views::MinimumFlexSizeRule::kScaleToZero)); |
| sync_info_container_->SetProperty( |
| views::kMarginsKey, gfx::Insets::VH(kDefaultMargin, kMenuEdgeMargin)); |
| |
| // Add icon + description at the top. |
| views::View* description_container = |
| sync_info_container_->AddChildView(std::make_unique<views::View>()); |
| description_container->SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::LayoutOrientation::kVertical, |
| views::MinimumFlexSizeRule::kPreferred, |
| views::MaximumFlexSizeRule::kUnbounded, true, |
| views::MinimumFlexSizeRule::kScaleToZero)); |
| views::FlexLayout* description_layout = |
| &description_container |
| ->SetLayoutManager(std::make_unique<views::FlexLayout>()) |
| ->SetOrientation(views::LayoutOrientation::kHorizontal) |
| .SetIgnoreDefaultMainAxisMargins(true) |
| .SetCollapseMargins(true) |
| .SetDefault(views::kMarginsKey, |
| gfx::Insets::VH(0, kDescriptionIconSpacing)); |
| |
| if (show_sync_badge) { |
| description_container->AddChildView(std::make_unique<SyncImageView>(this)); |
| } else { |
| // If there is no image, the description is centered. |
| description_layout->SetMainAxisAlignment(views::LayoutAlignment::kCenter); |
| } |
| |
| views::Label* label = description_container->AddChildView( |
| std::make_unique<views::Label>(description)); |
| label->SetMultiLine(true); |
| label->SetHandlesTooltips(false); |
| label->SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero, |
| views::MaximumFlexSizeRule::kPreferred, true)); |
| |
| // Set sync info description as the name of the parent container, so |
| // accessibility tools can read it together with the button text. The role |
| // change is required by Windows ATs. |
| sync_info_container_->GetViewAccessibility().OverrideRole( |
| ax::mojom::Role::kGroup); |
| sync_info_container_->GetViewAccessibility().OverrideName(description); |
| |
| // Add the prominent button at the bottom. |
| auto* button = |
| sync_info_container_->AddChildView(std::make_unique<views::MdTextButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| button_text)); |
| button->SetProminent(true); |
| |
| sync_info_background_callback_ = base::BindRepeating( |
| &ProfileMenuViewBase::BuildSyncInfoCallToActionBackground, |
| base::Unretained(this), background_color_id); |
| } |
| |
| void ProfileMenuViewBase::BuildSyncInfoWithoutCallToAction( |
| const std::u16string& text, |
| const base::RepeatingClosure& action) { |
| sync_info_container_->RemoveAllChildViews(); |
| sync_info_container_->SetLayoutManager(std::make_unique<views::FillLayout>()); |
| sync_info_container_->AddChildView(std::make_unique<SyncButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| this, text)); |
| |
| // No background required, so ui::ColorProvider isn't needed and |
| // |sync_info_background_callback_| can be set to base::DoNothing(). |
| sync_info_container_->SetBackground(nullptr); |
| sync_info_background_callback_ = base::DoNothing(); |
| } |
| |
| void ProfileMenuViewBase::AddShortcutFeatureButton( |
| const gfx::VectorIcon& icon, |
| const std::u16string& text, |
| base::RepeatingClosure action) { |
| const int kButtonSpacing = ChromeLayoutProvider::Get()->GetDistanceMetric( |
| views::DISTANCE_RELATED_BUTTON_HORIZONTAL); |
| |
| // Initialize layout if this is the first time a button is added. |
| if (!shortcut_features_container_->GetLayoutManager()) { |
| views::BoxLayout* layout = shortcut_features_container_->SetLayoutManager( |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, |
| gfx::Insets::TLBR(kDefaultMargin / 2, 0, kMenuEdgeMargin, 0), |
| kButtonSpacing)); |
| layout->set_main_axis_alignment( |
| views::BoxLayout::MainAxisAlignment::kCenter); |
| } |
| |
| views::Button* button = shortcut_features_container_->AddChildView( |
| std::make_unique<CircularImageButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| icon, text, |
| features::IsChromeRefresh2023() ? kCircularImageButtonRefreshSize |
| : kCircularImageButtonSize, |
| /*has_background_color=*/true, |
| /*show_border=*/!features::IsChromeRefresh2023())); |
| button->SetFlipCanvasOnPaintForRTLUI(false); |
| } |
| |
| void ProfileMenuViewBase::AddFeatureButton(const std::u16string& text, |
| base::RepeatingClosure action, |
| const gfx::VectorIcon& icon, |
| float icon_to_image_ratio) { |
| // Initialize layout if this is the first time a button is added. |
| if (!features_container_->GetLayoutManager()) { |
| features_container_->SetLayoutManager(std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| } |
| |
| if (&icon == &gfx::kNoneIcon) { |
| features_container_->AddChildView(std::make_unique<HoverButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| text)); |
| } else { |
| auto icon_view = |
| std::make_unique<FeatureButtonIconView>(icon, icon_to_image_ratio); |
| features_container_->AddChildView(std::make_unique<HoverButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| std::move(icon_view), text)); |
| } |
| } |
| |
| void ProfileMenuViewBase::SetProfileManagementHeading( |
| const std::u16string& heading) { |
| profile_mgmt_heading_ = heading; |
| |
| // Add separator before heading. |
| profile_mgmt_separator_container_->RemoveAllChildViews(); |
| profile_mgmt_separator_container_->SetLayoutManager( |
| std::make_unique<views::FillLayout>()); |
| profile_mgmt_separator_container_->SetBorder( |
| views::CreateEmptyBorder(gfx::Insets::VH(kDefaultMargin, 0))); |
| profile_mgmt_separator_container_->AddChildView( |
| std::make_unique<views::Separator>()); |
| |
| // Initialize heading layout. |
| profile_mgmt_heading_container_->RemoveAllChildViews(); |
| profile_mgmt_heading_container_->SetLayoutManager( |
| std::make_unique<views::FillLayout>()); |
| profile_mgmt_heading_container_->SetBorder(views::CreateEmptyBorder( |
| gfx::Insets::VH(kDefaultMargin, kMenuEdgeMargin))); |
| |
| // Add heading. |
| views::Label* label = profile_mgmt_heading_container_->AddChildView( |
| std::make_unique<views::Label>(heading, views::style::CONTEXT_LABEL, |
| views::style::STYLE_HINT)); |
| label->SetHorizontalAlignment(gfx::ALIGN_LEFT); |
| label->SetHandlesTooltips(false); |
| } |
| |
| void ProfileMenuViewBase::AddAvailableProfile(const ui::ImageModel& image_model, |
| const std::u16string& name, |
| bool is_guest, |
| bool is_enabled, |
| base::RepeatingClosure action) { |
| // Initialize layout if this is the first time a button is added. |
| if (!selectable_profiles_container_->GetLayoutManager()) { |
| selectable_profiles_container_->SetLayoutManager( |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| // Give the container an accessible name so accessibility tools can provide |
| // context for the buttons inside it. The role change is required by Windows |
| // ATs. |
| selectable_profiles_container_->GetViewAccessibility().OverrideRole( |
| ax::mojom::Role::kGroup); |
| selectable_profiles_container_->GetViewAccessibility().OverrideName( |
| profile_mgmt_heading_); |
| } |
| |
| DCHECK(!image_model.IsEmpty()); |
| ui::ImageModel sized_image = |
| SizeImageModel(image_model, profiles::kMenuAvatarIconSize); |
| views::Button* button = selectable_profiles_container_->AddChildView( |
| std::make_unique<HoverButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| sized_image, name)); |
| |
| button->SetEnabled(is_enabled); |
| |
| if (!is_guest && !first_profile_button_) |
| first_profile_button_ = button; |
| } |
| |
| void ProfileMenuViewBase::AddProfileManagementShortcutFeatureButton( |
| const gfx::VectorIcon& icon, |
| const std::u16string& text, |
| base::RepeatingClosure action) { |
| // Initialize layout if this is the first time a button is added. |
| if (!profile_mgmt_shortcut_features_container_->GetLayoutManager()) { |
| profile_mgmt_shortcut_features_container_->SetLayoutManager( |
| CreateBoxLayout(views::BoxLayout::Orientation::kHorizontal, |
| views::BoxLayout::CrossAxisAlignment::kCenter, |
| gfx::Insets::TLBR(0, 0, 0, kMenuEdgeMargin))); |
| } |
| |
| profile_mgmt_shortcut_features_container_->AddChildView( |
| std::make_unique<CircularImageButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| icon, text)); |
| } |
| |
| void ProfileMenuViewBase::AddProfileManagementManagedHint( |
| const gfx::VectorIcon& icon, |
| const std::u16string& text) { |
| // Initialize layout if this is the first time a button is added. |
| if (!profile_mgmt_shortcut_features_container_->GetLayoutManager()) { |
| profile_mgmt_shortcut_features_container_->SetLayoutManager( |
| CreateBoxLayout(views::BoxLayout::Orientation::kHorizontal, |
| views::BoxLayout::CrossAxisAlignment::kCenter, |
| gfx::Insets::TLBR(0, 0, 0, kMenuEdgeMargin))); |
| } |
| |
| views::ImageView* icon_button = |
| profile_mgmt_shortcut_features_container_->AddChildView( |
| std::make_unique<ProfileManagementIconView>(icon)); |
| icon_button->SetTooltipText(text); |
| } |
| |
| void ProfileMenuViewBase::AddProfileManagementFeatureButton( |
| const gfx::VectorIcon& icon, |
| const std::u16string& text, |
| base::RepeatingClosure action) { |
| // Initialize layout if this is the first time a button is added. |
| if (!profile_mgmt_features_container_->GetLayoutManager()) { |
| profile_mgmt_features_container_->SetLayoutManager( |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kVertical)); |
| } |
| |
| profile_mgmt_features_container_->AddChildView( |
| std::make_unique<ProfileManagementFeatureButton>( |
| base::BindRepeating(&ProfileMenuViewBase::ButtonPressed, |
| base::Unretained(this), std::move(action)), |
| icon, text)); |
| } |
| |
| gfx::ImageSkia ProfileMenuViewBase::ColoredImageForMenu( |
| const gfx::VectorIcon& icon, |
| ui::ColorId color) const { |
| return gfx::CreateVectorIcon(icon, kMaxImageSize, |
| GetColorProvider()->GetColor(color)); |
| } |
| |
| void ProfileMenuViewBase::RecordClick(ActionableItem item) { |
| // TODO(tangltom): Separate metrics for incognito and guest menu. |
| base::UmaHistogramEnumeration("Profile.Menu.ClickedActionableItem", item); |
| } |
| |
| int ProfileMenuViewBase::GetMaxHeight() const { |
| gfx::Rect anchor_rect = GetAnchorRect(); |
| gfx::Rect screen_space = |
| display::Screen::GetScreen() |
| ->GetDisplayNearestPoint(anchor_rect.CenterPoint()) |
| .work_area(); |
| int available_space = screen_space.bottom() - anchor_rect.bottom(); |
| #if BUILDFLAG(IS_WIN) |
| // On Windows the bubble can also be show to the top of the anchor. |
| available_space = |
| std::max(available_space, anchor_rect.y() - screen_space.y()); |
| #endif |
| return std::max(kMinimumScrollableContentHeight, available_space); |
| } |
| |
| void ProfileMenuViewBase::Reset() { |
| RemoveAllChildViews(); |
| |
| auto components = std::make_unique<views::View>(); |
| components->SetLayoutManager(std::make_unique<views::FlexLayout>()) |
| ->SetOrientation(views::LayoutOrientation::kVertical); |
| |
| // Create and add new component containers in the correct order. |
| // First, add the parts of the current profile. |
| identity_info_container_ = |
| components->AddChildView(std::make_unique<views::View>()); |
| shortcut_features_container_ = |
| components->AddChildView(std::make_unique<views::View>()); |
| sync_info_container_ = |
| components->AddChildView(std::make_unique<views::View>()); |
| features_container_ = |
| components->AddChildView(std::make_unique<views::View>()); |
| profile_mgmt_separator_container_ = |
| components->AddChildView(std::make_unique<views::View>()); |
| // Second, add the profile management header. This includes the heading and |
| // the shortcut feature(s) next to it. |
| auto profile_mgmt_header = std::make_unique<views::View>(); |
| views::BoxLayout* profile_mgmt_header_layout = |
| profile_mgmt_header->SetLayoutManager( |
| CreateBoxLayout(views::BoxLayout::Orientation::kHorizontal, |
| views::BoxLayout::CrossAxisAlignment::kCenter)); |
| profile_mgmt_heading_container_ = |
| profile_mgmt_header->AddChildView(std::make_unique<views::View>()); |
| profile_mgmt_header_layout->SetFlexForView(profile_mgmt_heading_container_, |
| 1); |
| profile_mgmt_shortcut_features_container_ = |
| profile_mgmt_header->AddChildView(std::make_unique<views::View>()); |
| profile_mgmt_header_layout->SetFlexForView( |
| profile_mgmt_shortcut_features_container_, 0); |
| components->AddChildView(std::move(profile_mgmt_header)); |
| // Third, add the profile management buttons. |
| selectable_profiles_container_ = |
| components->AddChildView(std::make_unique<views::View>()); |
| profile_mgmt_features_container_ = |
| components->AddChildView(std::make_unique<views::View>()); |
| first_profile_button_ = nullptr; |
| |
| // Create a scroll view to hold the components. |
| auto scroll_view = std::make_unique<views::ScrollView>(); |
| scroll_view->SetHorizontalScrollBarMode( |
| views::ScrollView::ScrollBarMode::kDisabled); |
| // TODO(https://crbug.com/871762): it's a workaround for the crash. |
| scroll_view->SetDrawOverflowIndicator(false); |
| scroll_view->ClipHeightTo(0, GetMaxHeight()); |
| scroll_view->SetContents(std::move(components)); |
| |
| // Create a table layout to set the menu width. |
| SetLayoutManager(std::make_unique<views::TableLayout>()) |
| ->AddColumn( |
| views::LayoutAlignment::kStretch, views::LayoutAlignment::kStretch, |
| views::TableLayout::kFixedSize, |
| views::TableLayout::ColumnSize::kFixed, kMenuWidth, kMenuWidth) |
| .AddRows(1, 1.0f); |
| AddChildView(std::move(scroll_view)); |
| } |
| |
| void ProfileMenuViewBase::FocusFirstProfileButton() { |
| if (first_profile_button_) |
| first_profile_button_->RequestFocus(); |
| } |
| |
| void ProfileMenuViewBase::BuildSyncInfoCallToActionBackground( |
| ui::ColorId background_color_id, |
| const ui::ColorProvider* color_provider) { |
| const int radius = views::LayoutProvider::Get()->GetCornerRadiusMetric( |
| views::Emphasis::kHigh); |
| sync_info_container_->SetBackground(views::CreateRoundedRectBackground( |
| color_provider->GetColor(background_color_id), radius, 1)); |
| sync_info_container_->SetBorder(views::CreatePaddedBorder( |
| views::CreateRoundedRectBorder( |
| 1, radius, color_provider->GetColor(ui::kColorMenuSeparator)), |
| gfx::Insets(kSyncInfoInsidePadding))); |
| } |
| |
| void ProfileMenuViewBase::Init() { |
| Reset(); |
| BuildMenu(); |
| } |
| |
| void ProfileMenuViewBase::OnThemeChanged() { |
| views::BubbleDialogDelegateView::OnThemeChanged(); |
| const auto* color_provider = GetColorProvider(); |
| SetBackground(views::CreateSolidBackground( |
| color_provider->GetColor(ui::kColorDialogBackground))); |
| sync_info_background_callback_.Run(color_provider); |
| } |
| |
| void ProfileMenuViewBase::OnWindowClosing() { |
| if (!anchor_button()) |
| return; |
| |
| views::InkDrop::Get(anchor_button()) |
| ->AnimateToState(views::InkDropState::DEACTIVATED, nullptr); |
| } |
| |
| bool ProfileMenuViewBase::HandleContextMenu( |
| content::RenderFrameHost& render_frame_host, |
| const content::ContextMenuParams& params) { |
| // Suppresses the context menu because some features, such as inspecting |
| // elements, are not appropriate in a bubble. |
| return true; |
| } |
| |
| void ProfileMenuViewBase::ButtonPressed(base::RepeatingClosure action) { |
| DCHECK(action); |
| signin_ui_util::RecordProfileMenuClick(browser()->profile()); |
| action.Run(); |
| } |
| |
| void ProfileMenuViewBase::CreateAXWidgetObserver(views::Widget* widget) { |
| ax_widget_observer_ = std::make_unique<AXMenuWidgetObserver>(this, widget); |
| } |
| |
| // Despite ProfileMenuViewBase being a dialog, we are enforcing it to behave |
| // like a menu from the accessibility POV because it fits better with a menu UX. |
| // The dialog exposes the kMenuBar role, and the top-level container is kMenu. |
| // This class is responsible for emitting menu accessible events when the dialog |
| // is activated or deactivated. |
| class ProfileMenuViewBase::AXMenuWidgetObserver : public views::WidgetObserver { |
| public: |
| AXMenuWidgetObserver(ProfileMenuViewBase* owner, views::Widget* widget) |
| : owner_(owner) { |
| observation_.Observe(widget); |
| } |
| ~AXMenuWidgetObserver() override = default; |
| |
| void OnWidgetActivationChanged(views::Widget* widget, bool active) override { |
| if (active) { |
| owner_->NotifyAccessibilityEvent(ax::mojom::Event::kMenuStart, true); |
| owner_->NotifyAccessibilityEvent(ax::mojom::Event::kMenuPopupStart, true); |
| } else { |
| owner_->NotifyAccessibilityEvent(ax::mojom::Event::kMenuPopupEnd, true); |
| owner_->NotifyAccessibilityEvent(ax::mojom::Event::kMenuEnd, true); |
| } |
| } |
| |
| private: |
| raw_ptr<ProfileMenuViewBase> owner_; |
| base::ScopedObservation<views::Widget, views::WidgetObserver> observation_{ |
| this}; |
| }; |