chromium/ash/user_education/views/help_bubble_view_ash.cc

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/user_education/views/help_bubble_view_ash.h"

#include <initializer_list>
#include <memory>
#include <numeric>
#include <optional>
#include <string>
#include <utility>

#include "ash/bubble/bubble_utils.h"
#include "ash/public/cpp/shell_window_ids.h"
#include "ash/style/pill_button.h"
#include "ash/style/style_util.h"
#include "ash/style/typography.h"
#include "ash/user_education/user_education_help_bubble_controller.h"
#include "ash/user_education/user_education_types.h"
#include "ash/user_education/user_education_util.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "base/metrics/user_metrics.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "base/types/pass_key.h"
#include "components/strings/grit/components_strings.h"
#include "components/user_education/common/help_bubble_params.h"
#include "components/vector_icons/vector_icons.h"
#include "third_party/skia/include/core/SkPath.h"
#include "ui/aura/window.h"
#include "ui/aura/window_targeter.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/base/ui_base_types.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.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/rect.h"
#include "ui/gfx/geometry/rect_f.h"
#include "ui/gfx/geometry/rounded_corners_f.h"
#include "ui/gfx/geometry/skia_conversions.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/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/event_monitor.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/metadata/view_factory.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"

namespace ash {

namespace {

// Minimum width of the bubble.
constexpr int kBubbleMinWidthDip = 200;
// Maximum width of the bubble. Longer strings will cause wrapping.
constexpr int kBubbleMaxWidthDip = 340;

// The insets from the bubble border to the text inside.
constexpr auto kBubbleContentsInsets = gfx::Insets::VH(16, 20);

// Corner radii for the help bubble. Note that when the help bubble is not
// center aligned with its anchor, the corner closest to the anchor has a
// smaller radius.
constexpr int kBubbleCornerRadiusDefault = 24;
constexpr int kBubbleCornerRadiusSmall = 2;

// Margins for the help bubble.
constexpr int kBubbleMargins = 8;

// Shadow elevation for the help bubble.
constexpr int kBubbleShadowElevation = 3;

// Translates from HelpBubbleArrow to the Views equivalent.
views::BubbleBorder::Arrow TranslateArrow(
    user_education::HelpBubbleArrow arrow) {
  switch (arrow) {
    case user_education::HelpBubbleArrow::kNone:
      return views::BubbleBorder::NONE;
    case user_education::HelpBubbleArrow::kTopLeft:
      return views::BubbleBorder::TOP_LEFT;
    case user_education::HelpBubbleArrow::kTopRight:
      return views::BubbleBorder::TOP_RIGHT;
    case user_education::HelpBubbleArrow::kBottomLeft:
      return views::BubbleBorder::BOTTOM_LEFT;
    case user_education::HelpBubbleArrow::kBottomRight:
      return views::BubbleBorder::BOTTOM_RIGHT;
    case user_education::HelpBubbleArrow::kLeftTop:
      return views::BubbleBorder::LEFT_TOP;
    case user_education::HelpBubbleArrow::kRightTop:
      return views::BubbleBorder::RIGHT_TOP;
    case user_education::HelpBubbleArrow::kLeftBottom:
      return views::BubbleBorder::LEFT_BOTTOM;
    case user_education::HelpBubbleArrow::kRightBottom:
      return views::BubbleBorder::RIGHT_BOTTOM;
    case user_education::HelpBubbleArrow::kTopCenter:
      return views::BubbleBorder::TOP_CENTER;
    case user_education::HelpBubbleArrow::kBottomCenter:
      return views::BubbleBorder::BOTTOM_CENTER;
    case user_education::HelpBubbleArrow::kLeftCenter:
      return views::BubbleBorder::LEFT_CENTER;
    case user_education::HelpBubbleArrow::kRightCenter:
      return views::BubbleBorder::RIGHT_CENTER;
  }
}

// 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 std::u16string accessible_name,
                   PressedCallback callback) {
    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, cros_tokens::kCrosSysOnSurface, kIconSize));

    constexpr float kCloseButtonFocusRingHaloThickness = 1.25f;
    views::FocusRing::Get(this)->SetHaloThickness(
        kCloseButtonFocusRingHaloThickness);
  }

  void OnThemeChanged() override {
    views::ImageButton::OnThemeChanged();
    StyleUtil::SetUpInkDropForButton(this);
    views::FocusRing::Get(this)->SetColorId(cros_tokens::kCrosSysOnSurface);
  }
};

BEGIN_METADATA(ClosePromoButton)
END_METADATA

class DotView : public views::View {
  METADATA_HEADER(DotView, views::View)

 public:
  DotView(gfx::Size size, bool should_fill)
      : 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(cros_tokens::kCrosSysOnSurface);
    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;

  const gfx::Size size_;
  const bool should_fill_;
};

constexpr int DotView::kStrokeWidth;

BEGIN_METADATA(DotView)
END_METADATA

// An `aura::WindowTargeter` that restricts located events to those within the
// area of the `gfx::Rect` given by `HelpBubbleViewAsh::GetHitRect()`.
class HelpBubbleWindowTargeter : public aura::WindowTargeter {
 public:
  explicit HelpBubbleWindowTargeter(HelpBubbleViewAsh* view)
      : view_tracker_(view) {}
  HelpBubbleWindowTargeter(const HelpBubbleWindowTargeter&) = delete;
  HelpBubbleWindowTargeter& operator=(const HelpBubbleWindowTargeter&) = delete;
  ~HelpBubbleWindowTargeter() override = default;

 private:
  // aura::WindowTargeter:
  std::unique_ptr<HitTestRects> GetExtraHitTestShapeRects(
      aura::Window* target) const override {
    if (!view_tracker_.view()) {
      return nullptr;
    }

    return std::make_unique<HitTestRects>(std::initializer_list<gfx::Rect>(
        {views::AsViewClass<const HelpBubbleViewAsh>(view_tracker_.view())
             ->GetHitRect()}));
  }

  const views::ViewTracker view_tracker_;
};

}  // namespace

DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleViewAsh,
                                      kHelpBubbleElementIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleViewAsh,
                                      kDefaultButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleViewAsh,
                                      kFirstNonDefaultButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleViewAsh, kBodyIconIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HelpBubbleViewAsh, kBodyTextIdForTesting);

// Explicitly don't use the default DIALOG_SHADOW as it will show a black
// outline in dark mode on Mac. Use our own shadow instead. The shadow type is
// the same for all other platforms.
HelpBubbleViewAsh::HelpBubbleViewAsh(
    HelpBubbleId id,
    const internal::HelpBubbleAnchorParams& anchor,
    user_education::HelpBubbleParams params)
    : BubbleDialogDelegateView(anchor.view,
                               TranslateArrow(params.arrow),
                               views::BubbleBorder::STANDARD_SHADOW),
      id_(id) {
  SetCanActivate(true);

  // When hosted within a `views::ScrollView`, the anchor view may be
  // (partially) outside the viewport. Ensure that the anchor view is visible.
  CHECK(anchor.view);
  anchor.view->ScrollViewToVisible();

  UseCompactMargins();

  // Default timeout depends on whether non-close buttons are present.
  timeout_ = params.timeout.value_or(
      params.buttons.empty() ? user_education::kDefaultTimeoutWithoutButtons
                             : user_education::kDefaultTimeoutWithButtons);
  if (!timeout_.is_zero()) {
    timeout_callback_ = std::move(params.timeout_callback);
  }
  SetCancelCallback(std::move(params.dismiss_callback));

  // A body text provided from extended properties should take precedence
  // over the default body text provided from help bubble `params` since
  // extended properties are the ChromeOS-specific mechanism for overriding
  // platform agnostic behaviors.
  std::u16string body_text;
  if (auto body_text_from_extended_properties =
          user_education_util::GetHelpBubbleBodyText(
              params.extended_properties)) {
    body_text = base::UTF8ToUTF16(body_text_from_extended_properties.value());
  } else {
    body_text = params.body_text;
  }

  // An accessible name provided from extended properties should take precedence
  // over the default accessible name provided from help bubble `params` since
  // extended properties are the ChromeOS-specific mechanism for overriding
  // platform agnostic behaviors.
  if (auto accessible_name_from_extended_properties =
          user_education_util::GetHelpBubbleAccessibleName(
              params.extended_properties)) {
    accessible_name_ =
        base::UTF8ToUTF16(accessible_name_from_extended_properties.value());
  } else {
    accessible_name_ = params.title_text;
    if (!accessible_name_.empty()) {
      accessible_name_ += u". ";
    }
    accessible_name_ +=
        params.screenreader_text.empty() ? 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>(
          gfx::Size(8, 8), i < params.progress->first));
    }
  } else {
    progress_container->SetVisible(false);
  }

  // A body icon provided from extended properties should take precedence over a
  // body icon provided from help bubble `params` since extended properties are
  // the ChromeOS-specific mechanism for overriding platform agnostic behaviors.
  const gfx::VectorIcon* body_icon = params.body_icon;
  if (auto body_icon_from_extended_properties =
          user_education_util::GetHelpBubbleBodyIcon(
              params.extended_properties)) {
    body_icon = &body_icon_from_extended_properties->get();
  }

  // Add the body icon (optional).
  constexpr int kBodyIconSize = 20;
  constexpr int kBodyIconBackgroundSize = 24;
  if (body_icon && (body_icon != &gfx::kNoneIcon)) {
    icon_view_ = top_text_container->AddChildViewAt(
        views::Builder<views::ImageView>()
            .SetAccessibleName(params.body_icon_alt_text)
            .SetImage(ui::ImageModel::FromVectorIcon(
                *body_icon, cros_tokens::kCrosSysDialogContainer,
                kBodyIconSize))
            .SetPreferredSize(
                gfx::Size(kBodyIconBackgroundSize, kBodyIconBackgroundSize))
            .SetProperty(views::kElementIdentifierKey, kBodyIconIdForTesting)
            .Build(),
        0);
  }

  // Add title (optional) and body label.
  if (!params.title_text.empty()) {
    labels_.push_back(
        top_text_container->AddChildView(bubble_utils::CreateLabel(
            TypographyToken::kCrosBody1, params.title_text)));
    views::Label* label = AddChildViewAt(
        bubble_utils::CreateLabel(TypographyToken::kCrosBody1, body_text),
        GetIndexOf(button_container).value());
    labels_.push_back(label);
    label->SetProperty(views::kElementIdentifierKey, kBodyTextIdForTesting);
  } else {
    views::Label* label = top_text_container->AddChildView(
        bubble_utils::CreateLabel(TypographyToken::kCrosBody1, body_text));
    labels_.push_back(label);
    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>(
              alt_text, base::BindRepeating(&DialogDelegate::CancelDialog,
                                            base::Unretained(this))));

  // Add other buttons.
  if (!params.buttons.empty()) {
    auto run_callback_and_close = [](HelpBubbleViewAsh* 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<views::LabelButton> default_button;
    for (user_education::HelpBubbleButtonParams& button_params :
         params.buttons) {
      auto button = std::make_unique<PillButton>(
          base::BindOnce(run_callback_and_close, base::Unretained(this),
                         std::move(button_params.callback)),
          button_params.text,
          button_params.is_default ? PillButton::Type::kPrimaryWithoutIcon
                                   : PillButton::Type::kSecondaryWithoutIcon);
      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 (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);

  // Create primary layout (vertical).
  SetLayoutManager(std::make_unique<views::FlexLayout>())
      ->SetOrientation(views::LayoutOrientation::kVertical)
      .SetMainAxisAlignment(views::LayoutAlignment::kCenter)
      .SetInteriorMargin(kBubbleContentsInsets)
      .SetCollapseMargins(true)
      .SetDefault(views::kMarginsKey,
                  gfx::Insets::TLBR(0, 0, default_spacing, 0))
      .SetIgnoreDefaultMainAxisMargins(true);

  // Set up top row container layout.
  const int kCloseButtonHeight = 24;
  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));
  }

  // Icon view should have padding between it and the title or body label.
  if (icon_view_) {
    icon_view_->SetProperty(views::kMarginsKey,
                            gfx::Insets::TLBR(0, 0, 0, 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 = kBubbleContentsInsets.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 the default spacing between bubble content and bottom/buttons.
  button_container->SetProperty(
      views::kMarginsKey,
      gfx::Insets::TLBR(
          layout_provider->GetDistanceMetric(
              views::DISTANCE_DIALOG_CONTENT_MARGIN_BOTTOM_CONTROL),
          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() >
      kBubbleMaxWidthDip - kBubbleContentsInsets.width()) {
    button_layout.SetOrientation(views::LayoutOrientation::kVertical)
        .SetCrossAxisAlignment(views::LayoutAlignment::kEnd)
        .SetDefault(views::kMarginsKey, gfx::Insets::VH(default_spacing, 0))
        .SetIgnoreDefaultMainAxisMargins(true);
  }

  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_);
  }

  SetModalType(
      user_education_util::GetHelpBubbleModalType(params.extended_properties));
  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);
  set_parent_window(
      anchor_widget()->GetNativeWindow()->GetRootWindow()->GetChildById(
          kShellWindowId_HelpBubbleContainer));

  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. Note that rounded corners are updated
  // *after* adjusting bounds since they are dependent on the help bubble's
  // position relative to its anchor.
  set_adjust_if_offscreen(true);
  SizeToContents();
  UpdateRoundedCorners();

  // Use a custom `aura::WindowTargeter` to avoid swallowing events from the
  // area outside the contents.
  // TODO(http://b/307780200): Possibly remove this after fixing the root issue
  // in the default `aura::WindowTargeter`.
  widget->GetNativeWindow()->SetEventTargeter(
      std::make_unique<HelpBubbleWindowTargeter>(this));

  if (widget->IsModal()) {
    // If the help bubble widget is a system modal widget, then it should be the
    // only interactive widget on the screen. Therefore, activate `widget`.
    widget->Show();
  } else {
    widget->ShowInactive();
  }

  auto* const anchor_bubble =
      anchor.view->GetWidget()->widget_delegate()->AsBubbleDialogDelegate();
  if (anchor_bubble) {
    anchor_pin_ = anchor_bubble->PreventCloseOnDeactivate();
  }
  MaybeStartAutoCloseTimer();

  // NOTE: `controller` may be `nullptr` in testing.
  if (auto* controller = UserEducationHelpBubbleController::Get()) {
    controller->NotifyHelpBubbleShown(base::PassKey<HelpBubbleViewAsh>(),
                                      /*help_bubble_view=*/this);
  }
}

HelpBubbleViewAsh::~HelpBubbleViewAsh() {
  // NOTE: `controller` may be `nullptr` in testing.
  if (auto* controller = UserEducationHelpBubbleController::Get()) {
    controller->NotifyHelpBubbleClosed(base::PassKey<HelpBubbleViewAsh>(),
                                       /*help_bubble_view=*/this);
  }
}

void HelpBubbleViewAsh::MaybeStartAutoCloseTimer() {
  if (timeout_.is_zero()) {
    return;
  }

  auto_close_timer_.Start(FROM_HERE, timeout_, this,
                          &HelpBubbleViewAsh::OnTimeout);
}

void HelpBubbleViewAsh::OnTimeout() {
  std::move(timeout_callback_).Run();
  GetWidget()->Close();
}

std::unique_ptr<views::NonClientFrameView>
HelpBubbleViewAsh::CreateNonClientFrameView(views::Widget* widget) {
  auto frame = BubbleDialogDelegateView::CreateNonClientFrameView(widget);
  auto* frame_ptr = static_cast<views::BubbleFrameView*>(frame.get());
  frame_ptr->bubble_border()->set_md_shadow_elevation(kBubbleShadowElevation);
  frame_ptr->set_use_anchor_window_bounds(false);
  return frame;
}

void HelpBubbleViewAsh::OnAnchorBoundsChanged() {
  views::BubbleDialogDelegateView::OnAnchorBoundsChanged();
  UpdateRoundedCorners();

  // NOTE: `controller` may be `nullptr` in testing.
  if (auto* controller = UserEducationHelpBubbleController::Get()) {
    controller->NotifyHelpBubbleAnchorBoundsChanged(
        base::PassKey<HelpBubbleViewAsh>(), /*help_bubble_view=*/this);
  }
}

std::u16string HelpBubbleViewAsh::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 HelpBubbleViewAsh::OnWidgetActivationChanged(views::Widget* widget,
                                                  bool active) {
  if (widget == GetWidget()) {
    if (active) {
      ++activate_count_;
      auto_close_timer_.AbandonAndStop();
    } else {
      MaybeStartAutoCloseTimer();
    }
  }
}

void HelpBubbleViewAsh::OnWidgetBoundsChanged(views::Widget* widget,
                                              const gfx::Rect& bounds) {
  views::BubbleDialogDelegateView::OnWidgetBoundsChanged(widget, bounds);
  UpdateRoundedCorners();
}

void HelpBubbleViewAsh::OnThemeChanged() {
  views::BubbleDialogDelegateView::OnThemeChanged();

  const auto* color_provider = GetColorProvider();
  const SkColor background_color =
      color_provider->GetColor(cros_tokens::kCrosSysDialogContainer);
  set_color(background_color);

  const SkColor foreground_color =
      color_provider->GetColor(cros_tokens::kCrosSysOnSurface);
  if (icon_view_) {
    icon_view_->SetBackground(views::CreateRoundedRectBackground(
        foreground_color, icon_view_->GetPreferredSize().height() / 2));
  }

  for (views::Label* label : labels_) {
    label->SetBackgroundColor(background_color);
    label->SetEnabledColor(foreground_color);
  }
}

gfx::Size HelpBubbleViewAsh::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() > kBubbleMaxWidthDip) {
    return gfx::Size(kBubbleMaxWidthDip,
                     GetLayoutManager()->GetPreferredHeightForWidth(
                         this, kBubbleMaxWidthDip));
  }

  if (layout_manager_preferred_size.width() < kBubbleMinWidthDip) {
    return gfx::Size(kBubbleMinWidthDip,
                     layout_manager_preferred_size.height());
  }

  return layout_manager_preferred_size;
}

gfx::Rect HelpBubbleViewAsh::GetAnchorRect() const {
  // Update `anchor_rect` to respect margins.
  gfx::Rect anchor_rect = BubbleDialogDelegateView::GetAnchorRect();
  anchor_rect.Outset(kBubbleMargins);

  // Update `anchor_rect` so that the anchor view and help bubble view are
  // corner-aligned instead of edge-aligned, as would be the default.
  switch (GetBubbleFrameView()->bubble_border()->arrow()) {
    case views::BubbleBorder::LEFT_TOP:
    case views::BubbleBorder::TOP_LEFT:
      anchor_rect = gfx::Rect(anchor_rect.bottom_right(), gfx::Size());
      break;
    case views::BubbleBorder::RIGHT_TOP:
    case views::BubbleBorder::TOP_RIGHT:
      anchor_rect = gfx::Rect(anchor_rect.bottom_left(), gfx::Size());
      break;
    case views::BubbleBorder::BOTTOM_LEFT:
    case views::BubbleBorder::LEFT_BOTTOM:
      anchor_rect = gfx::Rect(anchor_rect.top_right(), gfx::Size());
      break;
    case views::BubbleBorder::BOTTOM_RIGHT:
    case views::BubbleBorder::RIGHT_BOTTOM:
      anchor_rect = gfx::Rect(anchor_rect.origin(), gfx::Size());
      break;
    case views::BubbleBorder::BOTTOM_CENTER:
    case views::BubbleBorder::LEFT_CENTER:
    case views::BubbleBorder::RIGHT_CENTER:
    case views::BubbleBorder::TOP_CENTER:
    case views::BubbleBorder::NONE:
    case views::BubbleBorder::FLOAT:
      break;
  }

  return anchor_rect;
}

void HelpBubbleViewAsh::GetWidgetHitTestMask(SkPath* mask) const {
  mask->addRect(gfx::RectToSkRect(GetHitRect()));
}

bool HelpBubbleViewAsh::WidgetHasHitTestMask() const {
  return true;
}

// static
bool HelpBubbleViewAsh::IsHelpBubble(views::DialogDelegate* dialog) {
  auto* const contents = dialog->GetContentsView();
  return contents && views::IsViewClass<HelpBubbleViewAsh>(contents);
}

bool HelpBubbleViewAsh::IsFocusInHelpBubble() const {
#if BUILDFLAG(IS_MAC)
  if (close_button_ && close_button_->HasFocus()) {
    return true;
  }
  if (default_button_ && default_button_->HasFocus()) {
    return true;
  }
  for (auto* button : non_default_buttons_) {
    if (button->HasFocus()) {
      return true;
    }
  }
  return false;
#else
  return GetWidget()->IsActive();
#endif
}

views::LabelButton* HelpBubbleViewAsh::GetDefaultButtonForTesting() const {
  return default_button_;
}

views::LabelButton* HelpBubbleViewAsh::GetNonDefaultButtonForTesting(
    int index) const {
  return non_default_buttons_[index];
}

gfx::Rect HelpBubbleViewAsh::GetHitRect() const {
  // NOTE: Mask to bubble frame view contents bounds to exclude shadows.
  return GetBubbleFrameView()->GetContentsBounds();
}

void HelpBubbleViewAsh::UpdateRoundedCorners() {
  if (!GetWidget()) {
    return;
  }

  // Alias constants to avoid line wrapping below.
  constexpr float kDefault = kBubbleCornerRadiusDefault;
  constexpr float kSmall = kBubbleCornerRadiusSmall;

  // Cache anchor and help bubble bounds in screen coordinates.
  const gfx::Rect anchor_rect = GetAnchorRect();
  const gfx::Point anchor_center = anchor_rect.CenterPoint();
  const gfx::Rect bounds_rect = GetBoundsInScreen();
  const gfx::Point bounds_center = bounds_rect.CenterPoint();

  // When the help bubble is not center aligned with its anchor, the corner
  // closest to the anchor has a smaller radius.
  const int dx = anchor_center.x() - bounds_center.x();
  const int dy = anchor_center.y() - bounds_center.y();
  const float upper_left = dx < 0 && dy < 0 ? kSmall : kDefault;
  const float upper_right = dx > 0 && dy < 0 ? kSmall : kDefault;
  const float lower_right = dx > 0 && dy > 0 ? kSmall : kDefault;
  const float lower_left = dx < 0 && dy > 0 ? kSmall : kDefault;

  // Update rounded corners.
  GetBubbleFrameView()->bubble_border()->set_rounded_corners(
      gfx::RoundedCornersF(upper_left, upper_right, lower_right, lower_left));
}

BEGIN_METADATA(HelpBubbleViewAsh)
END_METADATA

}  // namespace ash