blob: e3c28bbec148a4ecd7590c290935519dc9f92610 [file] [log] [blame]
// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/user_education/views/help_bubble_view.h"
#include <initializer_list>
#include <memory>
#include <numeric>
#include <string>
#include <utility>
#include "base/callback_list.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "base/metrics/user_metrics.h"
#include "base/notreached.h"
#include "base/scoped_observation.h"
#include "base/strings/string_number_conversions.h"
#include "base/time/time.h"
#include "components/strings/grit/components_strings.h"
#include "components/user_education/common/help_bubble/help_bubble_params.h"
#include "components/user_education/views/help_bubble_delegate.h"
#include "components/user_education/views/help_bubble_event_relay.h"
#include "components/user_education/views/help_bubble_factory_views.h"
#include "components/user_education/views/help_bubble_views.h"
#include "components/variations/variations_associated_data.h"
#include "components/vector_icons/vector_icons.h"
#include "ui/base/interaction/element_identifier.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/mojom/dialog_button.mojom.h"
#include "ui/color/color_provider.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/point_f.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/rect_f.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/gfx/text_constants.h"
#include "ui/gfx/text_utils.h"
#include "ui/gfx/vector_icon_types.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/animation/ink_drop.h"
#include "ui/views/background.h"
#include "ui/views/bubble/bubble_border.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
#include "ui/views/bubble/bubble_frame_view.h"
#include "ui/views/controls/button/button.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/button/image_button_factory.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/controls/button/md_text_button.h"
#include "ui/views/controls/dot_indicator.h"
#include "ui/views/controls/focus_ring.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.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/layout_provider.h"
#include "ui/views/layout/layout_types.h"
#include "ui/views/style/platform_style.h"
#include "ui/views/style/typography.h"
#include "ui/views/vector_icons.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/view_tracker.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"
#include "ui/views/widget/widget_observer.h"
namespace user_education {
namespace {
class MdIPHBubbleButton : public views::MdTextButton {
METADATA_HEADER(MdIPHBubbleButton, views::MdTextButton)
public:
MdIPHBubbleButton(const HelpBubbleDelegate* delegate,
PressedCallback callback,
const std::u16string& text,
bool is_default_button)
: MdTextButton(std::move(callback), text),
delegate_(delegate),
is_default_button_(is_default_button) {
GetViewAccessibility().SetIsLeaf(true);
views::FocusRing::Get(this)->SetColorId(
delegate_->GetHelpBubbleForegroundColorId());
ui::ColorId foreground_color =
is_default_button_
? delegate_->GetHelpBubbleDefaultButtonForegroundColorId()
: delegate_->GetHelpBubbleForegroundColorId();
SetEnabledTextColors(foreground_color);
// TODO(crbug.com/40709599): Temporary fix for Mac. Bubble shouldn't be in
// inactive style when the bubble loses focus.
SetTextColor(ButtonState::STATE_DISABLED, foreground_color);
// The default behavior in 2023 refresh is for MD buttons is to have the
// alpha baked into the color, but we currently don't have that yet, so
// switch back to using the old default alpha blending mode.
auto* const ink_drop = views::InkDrop::Get(this);
ink_drop->SetBaseColor(
is_default_button_
? delegate_->GetHelpBubbleDefaultButtonForegroundColorId()
: delegate_->GetHelpBubbleForegroundColorId());
ink_drop->SetHighlightOpacity(std::nullopt);
}
MdIPHBubbleButton(const MdIPHBubbleButton&) = delete;
MdIPHBubbleButton& operator=(const MdIPHBubbleButton&) = delete;
~MdIPHBubbleButton() override = default;
void UpdateBackgroundColor() override {
// Prominent MD button does not have a border.
// Override this method to draw a border.
// Adapted from MdTextButton::UpdateBackgroundColor()
const auto* color_provider = GetColorProvider();
if (!color_provider)
return;
SkColor background_color = color_provider->GetColor(
is_default_button_
? delegate_->GetHelpBubbleDefaultButtonBackgroundColorId()
: delegate_->GetHelpBubbleBackgroundColorId());
if (GetState() == STATE_PRESSED) {
background_color =
GetNativeTheme()->GetSystemButtonPressedColor(background_color);
}
const SkColor stroke_color = color_provider->GetColor(
is_default_button_
? delegate_->GetHelpBubbleDefaultButtonBackgroundColorId()
: delegate_->GetHelpBubbleButtonBorderColorId());
SetBackground(CreateBackgroundFromPainter(
views::Painter::CreateRoundRectWith1PxBorderPainter(
background_color, stroke_color, GetCornerRadii())));
}
private:
const raw_ptr<const HelpBubbleDelegate> delegate_;
bool is_default_button_;
};
BEGIN_METADATA(MdIPHBubbleButton)
END_METADATA
// Displays a simple "X" close button that will close a promo bubble view.
// The alt-text and button callback can be set based on the needs of the
// specific bubble.
class ClosePromoButton : public views::ImageButton {
METADATA_HEADER(ClosePromoButton, views::ImageButton)
public:
ClosePromoButton(const HelpBubbleDelegate* delegate,
const std::u16string accessible_name,
PressedCallback callback)
: delegate_(delegate) {
SetCallback(std::move(callback));
views::ConfigureVectorImageButton(this);
views::HighlightPathGenerator::Install(
this,
std::make_unique<views::CircleHighlightPathGenerator>(gfx::Insets()));
GetViewAccessibility().SetName(accessible_name);
SetTooltipText(accessible_name);
constexpr int kIconSize = 16;
SetImageModel(views::ImageButton::STATE_NORMAL,
ui::ImageModel::FromVectorIcon(
views::kIcCloseIcon,
delegate_->GetHelpBubbleForegroundColorId(), kIconSize));
constexpr float kCloseButtonFocusRingHaloThickness = 1.25f;
views::FocusRing::Get(this)->SetHaloThickness(
kCloseButtonFocusRingHaloThickness);
}
void OnThemeChanged() override {
views::ImageButton::OnThemeChanged();
const auto* color_provider = GetColorProvider();
views::InkDrop::Get(this)->SetBaseColor(color_provider->GetColor(
delegate_->GetHelpBubbleCloseButtonInkDropColorId()));
views::FocusRing::Get(this)->SetColorId(
delegate_->GetHelpBubbleForegroundColorId());
}
private:
const raw_ptr<const HelpBubbleDelegate> delegate_;
};
BEGIN_METADATA(ClosePromoButton)
END_METADATA
class DotView : public views::View {
METADATA_HEADER(DotView, views::View)
public:
DotView(const HelpBubbleDelegate* delegate, gfx::Size size, bool should_fill)
: delegate_(delegate), size_(size), should_fill_(should_fill) {
// In order to anti-alias properly, we'll grow by the stroke width and then
// have the excess space be subtracted from the margins by the layout.
SetProperty(views::kInternalPaddingKey, gfx::Insets(kStrokeWidth));
}
~DotView() override = default;
// views::View:
gfx::Size CalculatePreferredSize(
const views::SizeBounds& available_size) const override {
gfx::Size size = size_;
const gfx::Insets* const insets = GetProperty(views::kInternalPaddingKey);
size.Enlarge(insets->width(), insets->height());
return size;
}
void OnPaint(gfx::Canvas* canvas) override {
gfx::RectF local_bounds = gfx::RectF(GetLocalBounds());
DCHECK_GT(local_bounds.width(), size_.width());
DCHECK_GT(local_bounds.height(), size_.height());
const gfx::PointF center_point = local_bounds.CenterPoint();
const float radius = (size_.width() - kStrokeWidth) / 2.0f;
const SkColor color = GetColorProvider()->GetColor(
delegate_->GetHelpBubbleForegroundColorId());
if (should_fill_) {
cc::PaintFlags fill_flags;
fill_flags.setStyle(cc::PaintFlags::kFill_Style);
fill_flags.setAntiAlias(true);
fill_flags.setColor(color);
canvas->DrawCircle(center_point, radius, fill_flags);
}
cc::PaintFlags stroke_flags;
stroke_flags.setStyle(cc::PaintFlags::kStroke_Style);
stroke_flags.setStrokeWidth(kStrokeWidth);
stroke_flags.setAntiAlias(true);
stroke_flags.setColor(color);
canvas->DrawCircle(center_point, radius, stroke_flags);
}
private:
static constexpr int kStrokeWidth = 1;
raw_ptr<const HelpBubbleDelegate> delegate_;
const gfx::Size size_;
const bool should_fill_;
};
constexpr int DotView::kStrokeWidth;
BEGIN_METADATA(DotView)
END_METADATA
} // namespace
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleView,
kHelpBubbleElementIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleView,
kDefaultButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleView,
kFirstNonDefaultButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleView, kCloseButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleView, kBodyTextIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleView, kTitleTextIdForTesting);
// TODO(https://crbug.com/382611284): Temporarily handle the case when the
// primary window is minimized by closing the help bubble. Remove this code when
// the issue is solved at the Views framework level.
#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_MAC)
class HelpBubbleView::PrimaryWidgetObserver : public views::WidgetObserver {
public:
explicit PrimaryWidgetObserver(HelpBubbleView& help_bubble)
: help_bubble_(help_bubble) {
observation_.Observe(help_bubble_->GetWidget()->GetPrimaryWindowWidget());
}
~PrimaryWidgetObserver() override = default;
private:
void OnWidgetDestroying(views::Widget*) override { observation_.Reset(); }
void OnWidgetShowStateChanged(views::Widget* widget) override {
if (widget->IsMinimized()) {
help_bubble_->GetWidget()->CloseWithReason(
views::Widget::ClosedReason::kLostFocus);
}
}
const raw_ref<HelpBubbleView> help_bubble_;
base::ScopedObservation<views::Widget, views::WidgetObserver> observation_{
this};
};
#endif
// Watches for the anchor view to be destroyed or removed from its widget.
// Used in cases where the anchor element is not the same as the anchor view.
// Prevents the help bubble from lingering after its anchor is invalid, which
// can cause crashes and other strange behavior.
class HelpBubbleView::AnchorViewObserver : public views::ViewObserver {
public:
AnchorViewObserver(views::View* anchor_view, HelpBubbleView* help_bubble)
: help_bubble_(help_bubble) {
observation_.Observe(anchor_view);
}
private:
// views::ViewObserver:
void OnViewRemovedFromWidget(views::View*) override { Detach(); }
void OnViewIsDeleting(views::View*) override { Detach(); }
void Detach() {
observation_.Reset();
if (help_bubble_->GetWidget() && !help_bubble_->GetWidget()->IsClosed()) {
help_bubble_->GetWidget()->Close();
}
}
const raw_ptr<HelpBubbleView> help_bubble_;
base::ScopedObservation<View, ViewObserver> observation_{this};
};
HelpBubbleView::HelpBubbleView(
const HelpBubbleDelegate* delegate,
const internal::HelpBubbleAnchorParams& anchor,
HelpBubbleParams params,
std::unique_ptr<HelpBubbleEventRelay> event_relay)
: BubbleDialogDelegateView(anchor.view,
HelpBubbleViews::TranslateArrow(params.arrow),
HelpBubbleFactoryViews::GetDefaultBubbleShadow(),
true),
delegate_(delegate),
event_relay_(std::move(event_relay)) {
SetBackgroundColor(delegate_->GetHelpBubbleBackgroundColorId());
if (anchor.rect.has_value()) {
SetForceAnchorRect(anchor.rect.value());
anchor_observer_ = std::make_unique<AnchorViewObserver>(anchor.view, this);
} else {
// When hosted within a `views::ScrollView`, the anchor view may be
// (partially) outside the viewport. Ensure that the anchor view is visible.
anchor.view->ScrollViewToVisible();
}
DCHECK(anchor.view)
<< "A bubble that closes on blur must be initially focused.";
UseCompactMargins();
// Default timeout depends on whether non-close buttons are present.
timeout_ = params.timeout.value_or(params.buttons.empty()
? kDefaultTimeoutWithoutButtons
: kDefaultTimeoutWithButtons);
timeout_callback_ = std::move(params.timeout_callback);
SetCancelCallback(std::move(params.dismiss_callback));
accessible_name_ = params.title_text;
if (!accessible_name_.empty())
accessible_name_ += u". ";
accessible_name_ += params.screenreader_text.empty()
? params.body_text
: params.screenreader_text;
screenreader_hint_text_ = params.keyboard_navigation_hint;
// Since we don't have any controls for the user to interact with (we're just
// an information bubble), override our role to kAlert.
SetAccessibleWindowRole(ax::mojom::Role::kAlert);
// Layout structure:
//
// [***ooo x] <--- progress container
// [@ TITLE x] <--- top text container
// body text
// [ cancel ok] <--- button container
//
// Notes:
// - The close button's placement depends on the presence of a progress
// indicator.
// - The body text takes the place of TITLE if there is no title.
// - If there is both a title and icon, the body text is manually indented to
// align with the title; this avoids having to nest an additional vertical
// container.
// - Unused containers are set to not be visible.
views::View* const progress_container =
AddChildView(std::make_unique<views::View>());
views::View* const top_text_container =
AddChildView(std::make_unique<views::View>());
views::View* const button_container =
AddChildView(std::make_unique<views::View>());
// Add progress indicator (optional) and its container.
if (params.progress) {
DCHECK(params.progress->second);
// TODO(crbug.com/40176811): surface progress information in a11y tree
for (int i = 0; i < params.progress->second; ++i) {
// TODO(crbug.com/40176811): formalize dot size
progress_container->AddChildView(std::make_unique<DotView>(
delegate, gfx::Size(8, 8), i < params.progress->first));
}
} else {
progress_container->SetVisible(false);
}
// Add the body icon (optional).
constexpr int kBodyIconSize = 20;
constexpr int kBodyIconBackgroundSize = 24;
if (params.body_icon) {
icon_view_ = top_text_container->AddChildViewAt(
std::make_unique<views::ImageView>(ui::ImageModel::FromVectorIcon(
*params.body_icon, delegate->GetHelpBubbleBackgroundColorId(),
kBodyIconSize)),
0);
icon_view_->SetPreferredSize(
gfx::Size(kBodyIconBackgroundSize, kBodyIconBackgroundSize));
icon_view_->GetViewAccessibility().SetName(params.body_icon_alt_text);
}
// Add title (optional) and body label.
if (!params.title_text.empty()) {
views::Label* const title_label =
top_text_container->AddChildView(std::make_unique<views::Label>(
params.title_text, delegate->GetTitleTextContext()));
title_label->SetProperty(views::kElementIdentifierKey,
kTitleTextIdForTesting);
labels_.push_back(title_label);
views::Label* const body_label =
AddChildViewAt(std::make_unique<views::Label>(
params.body_text, delegate->GetBodyTextContext()),
GetIndexOf(button_container).value());
labels_.push_back(body_label);
body_label->SetProperty(views::kElementIdentifierKey,
kBodyTextIdForTesting);
} else {
views::Label* const body_label =
top_text_container->AddChildView(std::make_unique<views::Label>(
params.body_text, delegate->GetBodyTextContext()));
labels_.push_back(body_label);
body_label->SetProperty(views::kElementIdentifierKey,
kBodyTextIdForTesting);
}
// Set common label properties.
for (views::Label* label : labels_) {
label->SetHorizontalAlignment(gfx::ALIGN_LEFT);
label->SetMultiLine(true);
label->SetElideBehavior(gfx::NO_ELIDE);
}
// Add close button.
std::u16string alt_text = params.close_button_alt_text;
// This can be empty if a test doesn't set it. Set a reasonable default to
// avoid an assertion (generated when a button with no text has no
// accessible name).
if (alt_text.empty()) {
alt_text = l10n_util::GetStringUTF16(IDS_CLOSE);
}
// Since we set the cancel callback, we will use CancelDialog() to dismiss.
close_button_ = (params.progress ? progress_container : top_text_container)
->AddChildView(std::make_unique<ClosePromoButton>(
delegate, alt_text,
base::BindRepeating(&DialogDelegate::CancelDialog,
base::Unretained(this))));
close_button_->SetProperty(views::kElementIdentifierKey,
kCloseButtonIdForTesting);
// Add other buttons.
if (!params.buttons.empty()) {
auto run_callback_and_close = [](HelpBubbleView* bubble_view,
base::OnceClosure callback) {
// We want to call the button callback before deleting the bubble in case
// the caller needs to do something with it, but the callback itself
// could close the bubble. Therefore, we need to ensure that the
// underlying bubble view is not deleted before trying to close it.
views::ViewTracker tracker(bubble_view);
std::move(callback).Run();
auto* const view = tracker.view();
if (view && view->GetWidget() && !view->GetWidget()->IsClosed())
view->GetWidget()->Close();
};
// We will hold the default button to add later, since where we add it in
// the sequence depends on platform style.
std::unique_ptr<MdIPHBubbleButton> default_button;
for (HelpBubbleButtonParams& button_params : params.buttons) {
auto button = std::make_unique<MdIPHBubbleButton>(
delegate,
base::BindOnce(run_callback_and_close, base::Unretained(this),
std::move(button_params.callback)),
button_params.text, button_params.is_default);
button->SetMinSize(gfx::Size(0, 0));
if (button_params.is_default) {
DCHECK(!default_button);
default_button = std::move(button);
default_button->SetProperty(views::kElementIdentifierKey,
kDefaultButtonIdForTesting);
} else {
non_default_buttons_.push_back(
button_container->AddChildView(std::move(button)));
}
}
if (!non_default_buttons_.empty()) {
non_default_buttons_.front()->SetProperty(
views::kElementIdentifierKey, kFirstNonDefaultButtonIdForTesting);
}
// Add the default button if there is one based on platform style.
if (default_button) {
if constexpr (views::PlatformStyle::kIsOkButtonLeading) {
default_button_ =
button_container->AddChildViewAt(std::move(default_button), 0);
} else {
default_button_ =
button_container->AddChildView(std::move(default_button));
}
}
} else {
button_container->SetVisible(false);
}
// Set up layouts. This is the default vertical spacing that is also used to
// separate progress indicators for symmetry.
// TODO(dfried): consider whether we could take font ascender and descender
// height and factor them into margin calculations.
const views::LayoutProvider* layout_provider = views::LayoutProvider::Get();
const int default_spacing = layout_provider->GetDistanceMetric(
views::DISTANCE_RELATED_CONTROL_VERTICAL);
// The insets from the bubble border to the text inside.
const gfx::Insets contents_insets = gfx::Insets(20);
// Create primary layout (vertical).
SetLayoutManager(std::make_unique<views::FlexLayout>())
->SetOrientation(views::LayoutOrientation::kVertical)
.SetMainAxisAlignment(views::LayoutAlignment::kCenter)
.SetInteriorMargin(contents_insets)
.SetCollapseMargins(true)
.SetDefault(views::kMarginsKey,
gfx::Insets::TLBR(0, 0, default_spacing, 0))
.SetIgnoreDefaultMainAxisMargins(true);
// Set up top row container layout.
const int kCloseButtonHeight = 20;
auto& progress_layout =
progress_container
->SetLayoutManager(std::make_unique<views::FlexLayout>())
->SetOrientation(views::LayoutOrientation::kHorizontal)
.SetCrossAxisAlignment(views::LayoutAlignment::kCenter)
.SetMinimumCrossAxisSize(kCloseButtonHeight)
.SetDefault(views::kMarginsKey,
gfx::Insets::TLBR(0, default_spacing, 0, 0))
.SetIgnoreDefaultMainAxisMargins(true);
progress_container->SetProperty(
views::kFlexBehaviorKey,
views::FlexSpecification(progress_layout.GetDefaultFlexRule()));
// Close button should float right in whatever container it's in.
if (close_button_) {
close_button_->SetProperty(
views::kFlexBehaviorKey,
views::FlexSpecification(views::LayoutOrientation::kHorizontal,
views::MinimumFlexSizeRule::kPreferred,
views::MaximumFlexSizeRule::kUnbounded)
.WithAlignment(views::LayoutAlignment::kEnd));
close_button_->SetProperty(views::kMarginsKey,
gfx::Insets::TLBR(0, default_spacing, 0, 0));
close_button_->SetProperty(views::kElementIdentifierKey,
kCloseButtonIdForTesting);
}
// Icon view should have padding between it and the title or body label.
if (icon_view_) {
// When there is no title, distance from icon and body text to buttons can
// appear cramped, so add a small bit of extra margin.
const int bottom_margin =
!params.buttons.empty() && params.title_text.empty() ? 2 : 0;
icon_view_->SetProperty(
views::kMarginsKey,
gfx::Insets::TLBR(0, 0, bottom_margin, default_spacing));
}
// Set label flex properties. This ensures that if the width of the bubble
// maxes out the text will shrink on the cross-axis and grow to multiple
// lines without getting cut off.
const views::FlexSpecification text_flex(
views::LayoutOrientation::kVertical,
views::MinimumFlexSizeRule::kPreferred,
views::MaximumFlexSizeRule::kPreferred,
/* adjust_height_for_width = */ true,
views::MinimumFlexSizeRule::kScaleToMinimum);
for (views::Label* label : labels_)
label->SetProperty(views::kFlexBehaviorKey, text_flex);
auto& top_text_layout =
top_text_container
->SetLayoutManager(std::make_unique<views::FlexLayout>())
->SetOrientation(views::LayoutOrientation::kHorizontal)
.SetCrossAxisAlignment(views::LayoutAlignment::kStart)
.SetIgnoreDefaultMainAxisMargins(true);
top_text_container->SetProperty(
views::kFlexBehaviorKey,
views::FlexSpecification(top_text_layout.GetDefaultFlexRule()));
// If the body icon is present, labels after the first are not parented to
// the top text container, but still need to be inset to align with the
// title.
if (icon_view_) {
const int indent =
contents_insets.left() + kBodyIconBackgroundSize + default_spacing;
for (size_t i = 1; i < labels_.size(); ++i) {
labels_[i]->SetProperty(views::kMarginsKey,
gfx::Insets::TLBR(0, indent, 0, 0));
}
}
// Set up button container layout.
// Add in spacing between bubble content and bottom/buttons.
button_container->SetProperty(
views::kMarginsKey,
gfx::Insets::TLBR(layout_provider->GetDistanceMetric(
views::DISTANCE_DIALOG_CONTENT_MARGIN_BOTTOM_TEXT),
0, 0, 0));
// Create button container internal layout.
auto& button_layout =
button_container->SetLayoutManager(std::make_unique<views::FlexLayout>())
->SetOrientation(views::LayoutOrientation::kHorizontal)
.SetMainAxisAlignment(views::LayoutAlignment::kEnd)
.SetDefault(
views::kMarginsKey,
gfx::Insets::TLBR(0,
layout_provider->GetDistanceMetric(
views::DISTANCE_RELATED_BUTTON_HORIZONTAL),
0, 0))
.SetIgnoreDefaultMainAxisMargins(true);
// In a handful of (mostly South-Asian) languages, button text can exceed the
// available width in the bubble if buttons are aligned horizontally. In those
// cases - and only those cases - the bubble can switch to a vertical button
// alignment.
if (button_container->GetMinimumSize().width() >
kMaxWidthDip - contents_insets.width()) {
button_layout.SetOrientation(views::LayoutOrientation::kVertical)
.SetCrossAxisAlignment(views::LayoutAlignment::kEnd)
.SetDefault(views::kMarginsKey, gfx::Insets::VH(default_spacing, 0))
.SetIgnoreDefaultMainAxisMargins(true);
// Calculate the closest the bubble can be to the normal max width without
// cutting off an especially long button caption.
max_bubble_width_ =
std::max(kMaxWidthDip, button_container->GetMinimumSize().width() +
contents_insets.width());
}
button_container->SetProperty(
views::kFlexBehaviorKey,
views::FlexSpecification(button_layout.GetDefaultFlexRule()));
// Want a consistent initial focused view if one is available.
if (!button_container->children().empty()) {
SetInitiallyFocusedView(button_container->children()[0]);
} else if (close_button_) {
SetInitiallyFocusedView(close_button_);
}
SetProperty(views::kElementIdentifierKey, kHelpBubbleElementIdForTesting);
set_margins(gfx::Insets());
set_title_margins(gfx::Insets());
SetButtons(static_cast<int>(ui::mojom::DialogButton::kNone));
set_close_on_deactivate(false);
set_focus_traversable_from_anchor_view(false);
const bool suppress_events =
event_relay_ && !event_relay_->ShouldHelpBubbleProcessEvents();
if (suppress_events) {
CHECK_LE(params.buttons.size(), 1U)
<< "Help bubbles that cannot activate cannot have multiple interactive "
"buttons due to accessibility constraints.";
SetCanActivate(false);
set_accept_events(false);
}
views::Widget* widget = views::BubbleDialogDelegateView::CreateBubble(this);
// This gets reset to the platform default when we call CreateBubble(), so we
// have to change it afterwards:
set_adjust_if_offscreen(true);
auto* const frame_view = GetBubbleFrameView();
frame_view->SetDisplayVisibleArrow(anchor.show_arrow &&
params.arrow != HelpBubbleArrow::kNone);
// If the primary window widget is not the anchor widget, do not use the
// window anchor bounds.
if (anchor_widget()->GetPrimaryWindowWidget() != anchor_widget()) {
frame_view->set_use_anchor_window_bounds(false);
}
// Bubbles get a 1-dip border that's either light or dark depending on system
// light or dark mode, but this does not match with the help bubble (see
// b/303069420).
frame_view->bubble_border()->set_draw_border_stroke(false);
// TODO(crbug.com/41493925) Remove this InvalidateLayout() once the border
// invalidate itself when it changes.
InvalidateLayout();
// Setup that should happen after the widget is constructed:
if (suppress_events) {
// This is required on Windows because of the way events are routed.
GetBubbleFrameView()->set_hit_test_transparent(true);
}
if (auto* const anchor_bubble =
anchor_widget()->widget_delegate()->AsBubbleDialogDelegate()) {
// Make sure that if the help bubble is attaching to a dialog, the dialog
// does not immediately dismiss when the help bubble is shown or focused.
anchor_pin_ = anchor_bubble->PreventCloseOnDeactivate();
}
// Most help bubbles with buttons take focus when they show.
const bool show_active =
params.focus_on_show_hint.value_or(!params.buttons.empty()) &&
!event_relay_;
if (show_active) {
widget->Show();
} else {
widget->ShowInactive();
}
// Begin event-forwarding if appropriate.
if (event_relay_) {
event_relay_->Init(this);
}
#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_MAC)
primary_widget_observer_ = std::make_unique<PrimaryWidgetObserver>(*this);
#endif
MaybeStartAutoCloseTimer();
}
HelpBubbleView::~HelpBubbleView() = default;
void HelpBubbleView::MaybeStartAutoCloseTimer() {
if (timeout_.is_zero())
return;
auto_close_timer_.Start(FROM_HERE, timeout_, this,
&HelpBubbleView::OnTimeout);
}
void HelpBubbleView::OnTimeout() {
if (timeout_callback_) {
std::move(timeout_callback_).Run();
}
GetWidget()->Close();
}
std::u16string HelpBubbleView::GetAccessibleWindowTitle() const {
std::u16string result = accessible_name_;
// If there's a keyboard navigation hint, append it after a full stop.
if (!screenreader_hint_text_.empty() && activate_count_ <= 1)
result += u". " + screenreader_hint_text_;
return result;
}
void HelpBubbleView::OnWidgetActivationChanged(views::Widget* widget,
bool active) {
if (widget == GetWidget()) {
if (active) {
++activate_count_;
auto_close_timer_.Stop();
widget->UpdateAccessibleNameForRootView();
} else {
MaybeStartAutoCloseTimer();
}
}
}
void HelpBubbleView::OnThemeChanged() {
views::BubbleDialogDelegateView::OnThemeChanged();
const auto* color_provider = GetColorProvider();
const SkColor foreground_color =
color_provider->GetColor(delegate_->GetHelpBubbleForegroundColorId());
if (icon_view_) {
icon_view_->SetBackground(views::CreateRoundedRectBackground(
foreground_color, icon_view_->GetPreferredSize({}).height() / 2));
}
const SkColor background_color =
color_provider->GetColor(delegate_->GetHelpBubbleBackgroundColorId());
for (views::Label* label : labels_) {
label->SetBackgroundColor(background_color);
label->SetEnabledColor(foreground_color);
}
}
gfx::Size HelpBubbleView::CalculatePreferredSize(
const views::SizeBounds& available_size) const {
const gfx::Size layout_manager_preferred_size =
View::CalculatePreferredSize(available_size);
// Wrap if the width is larger than |kBubbleMaxWidthDip|.
if (layout_manager_preferred_size.width() > max_bubble_width_) {
return gfx::Size(max_bubble_width_,
GetLayoutManager()->GetPreferredHeightForWidth(
this, max_bubble_width_));
}
if (layout_manager_preferred_size.width() < kMinWidthDip) {
return gfx::Size(kMinWidthDip, layout_manager_preferred_size.height());
}
return layout_manager_preferred_size;
}
gfx::Rect HelpBubbleView::GetAnchorRect() const {
gfx::Rect default_anchor_rect = BubbleDialogDelegateView::GetAnchorRect();
if (!local_anchor_bounds_) {
return default_anchor_rect;
}
// Ensure that we are not trying to clamp the anchor bounds to a completely
// empty bounds.
gfx::Size size = default_anchor_rect.size();
size.SetToMax({1, 1});
// Clamp the local bounds to the size of the anchor view.
const int left = std::clamp(local_anchor_bounds_->x(), 0, size.width() - 1);
const int right = std::clamp(local_anchor_bounds_->right(), 1, size.width());
const int top = std::clamp(local_anchor_bounds_->y(), 0, size.height() - 1);
const int bottom =
std::clamp(local_anchor_bounds_->bottom(), 1, size.height());
gfx::Rect result(left, top, right - left, bottom - top);
// Translate back to screen coordinates.
result.Offset(default_anchor_rect.OffsetFromOrigin());
return result;
}
void HelpBubbleView::OnBeforeBubbleWidgetInit(views::Widget::InitParams* params,
views::Widget* widget) const {
BubbleDialogDelegateView::OnBeforeBubbleWidgetInit(params, widget);
#if BUILDFLAG(IS_LINUX)
// Help bubbles anchored to menus may be clipped to their anchors' bounds,
// resulting in visual errors, unless they use accelerated rendering. See
// crbug.com/1445770 for details. This also applies to bubbles anchored to
// all accelerated windows below a certain size, especially those which are
// not top-level application windows (see crbug.com/340523110).
//
// Because it is not possible to know exactly if a bubble will correctly fit
// in the bounds of its ancestor accelerator widget, due to things like
// anchor positioning and the possibility that a window size could change,
// make all Linux help bubbles accelerated.
//
// Note: accelerated widgets are "desktop native" widgets and interact with
// the OS window activation system; this is, in turn, a problem for Linux
// because of known technical limitations around window activation. See the
// following bug for more information regarding window activation issues in
// Weston, the windowing environment used on chrome's Wayland test-bots:
// https://gitlab.freedesktop.org/wayland/weston/-/issues/669
params->use_accelerated_widget_override = true;
#endif
}
// static
bool HelpBubbleView::IsHelpBubble(views::DialogDelegate* dialog) {
auto* const contents = dialog->GetContentsView();
return contents && views::IsViewClass<HelpBubbleView>(contents);
}
views::LabelButton* HelpBubbleView::GetDefaultButtonForTesting() const {
return default_button_;
}
views::LabelButton* HelpBubbleView::GetNonDefaultButtonForTesting(
int index) const {
return non_default_buttons_[index];
}
void HelpBubbleView::SetForceAnchorRect(gfx::Rect force_anchor_rect) {
force_anchor_rect.Offset(
-views::BubbleDialogDelegateView::GetAnchorRect().OffsetFromOrigin());
local_anchor_bounds_ = force_anchor_rect;
}
BEGIN_METADATA(HelpBubbleView)
END_METADATA
} // namespace user_education