chromium/ash/system/toast/system_nudge_view.cc

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

#include "ash/system/toast/system_nudge_view.h"

#include <algorithm>
#include <string>
#include <utility>
#include <vector>

#include "ash/public/cpp/ash_view_ids.h"
#include "ash/public/cpp/style/color_provider.h"
#include "ash/public/cpp/system/anchored_nudge_data.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/keyboard_shortcut_view.h"
#include "ash/style/pill_button.h"
#include "ash/style/system_shadow.h"
#include "ash/style/typography.h"
#include "ash/system/toast/nudge_constants.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/layer.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/point.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/views/background.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/highlight_border.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/view.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/view_tracker.h"

namespace ash {

namespace {

constexpr int kFocusableViewsGroupId = 1;

// Nudge constants
constexpr gfx::Insets kNudgeInteriorMargin = gfx::Insets::VH(20, 20);
constexpr gfx::Insets kTextOnlyNudgeInteriorMargin = gfx::Insets::VH(12, 20);

constexpr gfx::Insets kNudgeWithCloseButton_InteriorMargin =
    gfx::Insets::TLBR(8, 20, 20, 8);
constexpr gfx::Insets
    kNudgeWithCloseButton_ImageAndTextContainerInteriorMargin =
        gfx::Insets::TLBR(12, 0, 0, 12);
constexpr gfx::Insets kNudgeWithCloseButton_ButtonContainerInteriorMargin =
    gfx::Insets::TLBR(0, 0, 0, 12);

constexpr float kNudgeCornerRadius = 24.0f;
constexpr float kNudgePointyCornerRadius = 4.0f;

// Label constants
constexpr int kBodyLabelMaxLines = 3;

// Image constants
constexpr int kImageViewSize = 60;
constexpr int kImageViewCornerRadius = 12;

// Button constants
constexpr gfx::Insets kButtonsMargins = gfx::Insets::VH(0, 8);

// Padding constants
constexpr int kButtonContainerTopPadding = 16;
constexpr int kImageViewTrailingPadding = 16;
constexpr int kTitleBottomPadding = 4;

void AddPaddingView(views::View* parent, int width, int height) {
  parent->AddChildView(std::make_unique<views::View>())
      ->SetPreferredSize(gfx::Size(width, height));
}

// Returns true if the provided arrow is located at a corner.
bool CalculateIsCornerAnchored(views::BubbleBorder::Arrow arrow) {
  switch (arrow) {
    case views::BubbleBorder::Arrow::TOP_LEFT:
    case views::BubbleBorder::Arrow::TOP_RIGHT:
    case views::BubbleBorder::Arrow::BOTTOM_LEFT:
    case views::BubbleBorder::Arrow::BOTTOM_RIGHT:
    case views::BubbleBorder::Arrow::LEFT_TOP:
    case views::BubbleBorder::Arrow::RIGHT_TOP:
    case views::BubbleBorder::Arrow::LEFT_BOTTOM:
    case views::BubbleBorder::Arrow::RIGHT_BOTTOM:
      return true;
    default:
      return false;
  }
}

// Returns a `gfx::RoundedCornersF` object that has a single pointy corner which
// is defined based on the nudge's position in relation to its anchor view.
gfx::RoundedCornersF CalculatePointyAnchoredNudgeCorners(
    views::View* nudge_view,
    views::View* anchor_view) {
  auto nudge_bounds = nudge_view->GetBoundsInScreen();
  auto anchor_bounds = anchor_view->GetBoundsInScreen();

  bool pointy_bottom = nudge_bounds.bottom() < anchor_bounds.bottom();
  bool pointy_right = nudge_bounds.right() < anchor_bounds.right();

  auto top_left_radius = !pointy_bottom && !pointy_right
                             ? kNudgePointyCornerRadius
                             : kNudgeCornerRadius;
  auto top_right_radius = !pointy_bottom && pointy_right
                              ? kNudgePointyCornerRadius
                              : kNudgeCornerRadius;
  auto bottom_right_radius = pointy_bottom && pointy_right
                                 ? kNudgePointyCornerRadius
                                 : kNudgeCornerRadius;
  auto bottom_left_radius = pointy_bottom && !pointy_right
                                ? kNudgePointyCornerRadius
                                : kNudgeCornerRadius;

  return gfx::RoundedCornersF(top_left_radius, top_right_radius,
                              bottom_right_radius, bottom_left_radius);
}

}  // namespace

DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemNudgeView, kBubbleIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemNudgeView,
                                      kPrimaryButtonIdForTesting);
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemNudgeView,
                                      kSecondaryButtonIdForTesting);

// FocusableChildrenObserver --------------------------------------------------

// Observes child view's focus state changes.
class SystemNudgeView::FocusableChildrenObserver : public views::ViewObserver {
 public:
  FocusableChildrenObserver(
      std::vector<views::View*> observed_children,
      base::RepeatingCallback<void(/*has_focus=*/bool)> focus_callback)
      : observed_children_(std::move(observed_children)),
        focus_callback_(std::move(focus_callback)) {
    for (views::View* observed_child : observed_children_) {
      observed_child->AddObserver(this);
    }
  }

  FocusableChildrenObserver(const FocusableChildrenObserver&) = delete;
  FocusableChildrenObserver& operator=(const FocusableChildrenObserver&) =
      delete;

  ~FocusableChildrenObserver() override {
    for (views::View* observed_child : observed_children_) {
      observed_child->RemoveObserver(this);
    }
  }

 private:
  // ViewObserver:
  void OnViewFocused(views::View* observed_view) override {
    focus_callback_.Run(/*has_focus=*/true);
  }

  void OnViewBlurred(views::View* observed_view) override {
    focus_callback_.Run(/*has_focus=*/false);
  }

  const std::vector<views::View*> observed_children_;

  base::RepeatingCallback<void(/*has_focus=*/bool)> focus_callback_;
};

// SystemNudgeView ------------------------------------------------------------

SystemNudgeView::SystemNudgeView(
    const AnchoredNudgeData& nudge_data,
    base::RepeatingCallback<void(/*is_hovered_or_has_focus=*/bool)>
        hover_or_focus_changed_callback)
    : shadow_(SystemShadow::CreateShadowOnTextureLayer(
          SystemShadow::Type::kElevation4)),
      is_corner_anchored_(CalculateIsCornerAnchored(nudge_data.arrow)),
      hover_changed_callback_(std::move(nudge_data.hover_changed_callback)),
      hover_or_focus_changed_callback_(
          std::move(hover_or_focus_changed_callback)) {
  // Painted to layer so the view can be semi-transparent and set rounded
  // corners.
  SetPaintToLayer();
  layer()->SetFillsBoundsOpaquely(false);
  layer()->SetBackgroundBlur(ColorProvider::kBackgroundBlurSigma);
  layer()->SetBackdropFilterQuality(ColorProvider::kBackgroundBlurQuality);
  SetBackground(views::CreateThemedSolidBackground(
      nudge_data.background_color_id.value_or(kColorAshShieldAndBase80)));
  SetNotifyEnterExitOnChild(true);
  SetProperty(views::kElementIdentifierKey, kBubbleIdForTesting);

  // Cache the anchor view when the nudge anchors by its corner to set a pointy
  // corner based on the nudge's position in relation to this anchor view.
  if (nudge_data.is_anchored() && is_corner_anchored_) {
    anchor_view_tracker_ = std::make_unique<views::ViewTracker>();
    anchor_view_tracker_->SetView(nudge_data.GetAnchorView());
    SetNudgeRoundedCornerRadius(CalculatePointyAnchoredNudgeCorners(
        /*nudge_view=*/this, anchor_view_tracker_->view()));
  } else {
    SetNudgeRoundedCornerRadius(gfx::RoundedCornersF(kNudgeCornerRadius));
  }

  SetOrientation(views::LayoutOrientation::kVertical);
  SetInteriorMargin(kNudgeInteriorMargin);
  SetCrossAxisAlignment(views::LayoutAlignment::kStretch);

  // TODO(crbug.com/40232718): See View::SetLayoutManagerUseConstrainedSpace
  SetLayoutManagerUseConstrainedSpace(false);

  const bool nudge_is_text_only = nudge_data.image_model.IsEmpty() &&
                                  nudge_data.title_text.empty() &&
                                  nudge_data.primary_button_text.empty() &&
                                  nudge_data.keyboard_codes.empty();

  // Nudges without an anchor view that are not text-only will have a close
  // button that is visible on view hovered.
  const bool has_close_button =
      !nudge_data.is_anchored() && !nudge_is_text_only;

  views::View* image_and_text_container;
  auto image_and_text_container_unique =
      views::Builder<views::FlexLayoutView>()
          .SetOrientation(views::LayoutOrientation::kHorizontal)
          .SetCrossAxisAlignment(views::LayoutAlignment::kStart)
          .SetInteriorMargin(
              has_close_button
                  ? kNudgeWithCloseButton_ImageAndTextContainerInteriorMargin
                  : gfx::Insets())
          .Build();

  if (has_close_button) {
    SetInteriorMargin(kNudgeWithCloseButton_InteriorMargin);

    // Set the `image_and_text_container` parent to use a `FillLayout` so it can
    // allow for overlap with the close button.
    auto* fill_layout_container = AddChildView(std::make_unique<views::View>());
    fill_layout_container->SetLayoutManager(
        std::make_unique<views::FillLayout>());

    image_and_text_container = fill_layout_container->AddChildView(
        std::move(image_and_text_container_unique));

    auto* close_button_container = fill_layout_container->AddChildView(
        views::Builder<views::FlexLayoutView>()
            .SetOrientation(views::LayoutOrientation::kHorizontal)
            .SetMainAxisAlignment(views::LayoutAlignment::kEnd)
            .SetCrossAxisAlignment(views::LayoutAlignment::kStart)
            .Build());

    close_button_ = close_button_container->AddChildView(
        views::Builder<views::ImageButton>()
            .SetID(VIEW_ID_SYSTEM_NUDGE_CLOSE_BUTTON)
            .SetCallback(std::move(nudge_data.close_button_callback))
            .SetImageModel(views::Button::STATE_NORMAL,
                           ui::ImageModel::FromVectorIcon(
                               kCloseSmallIcon, cros_tokens::kCrosSysOnSurface))
            .SetTooltipText(l10n_util::GetStringUTF16(
                IDS_ASH_SYSTEM_NUDGE_CLOSE_BUTTON_TOOLTIP))
            .SetVisible(false)
            .Build());
  } else {
    image_and_text_container =
        AddChildView(std::move(image_and_text_container_unique));
  }

  const bool has_image = !nudge_data.image_model.IsEmpty();
  if (has_image) {
    auto* image_view = image_and_text_container->AddChildView(
        views::Builder<views::ImageView>()
            .SetID(VIEW_ID_SYSTEM_NUDGE_IMAGE_VIEW)
            .SetPreferredSize(gfx::Size(kImageViewSize, kImageViewSize))
            .SetImage(nudge_data.image_model)
            // Painted to layer to set rounded corners.
            .SetPaintToLayer()
            .Build());
    // Certain `ImageModels` do not have the ability to set their size in the
    // constructor, so instead we can do it here.
    if (nudge_data.fill_image_size) {
      image_view->SetImageSize(gfx::Size(kImageViewSize, kImageViewSize));
    }
    image_view->layer()->SetFillsBoundsOpaquely(false);
    image_view->layer()->SetRoundedCornerRadius(
        gfx::RoundedCornersF(kImageViewCornerRadius));

    if (nudge_data.image_background_color_id) {
      image_view->SetBackground(views::CreateThemedSolidBackground(
          *nudge_data.image_background_color_id));
    }

    AddPaddingView(image_and_text_container, kImageViewTrailingPadding,
                   kImageViewSize);
  }

  const bool has_title = !nudge_data.title_text.empty();
  auto* text_container = image_and_text_container->AddChildView(
      views::Builder<views::FlexLayoutView>()
          .SetOrientation(views::LayoutOrientation::kVertical)
          .SetProperty(
              views::kFlexBehaviorKey,
              views::FlexSpecification(views::LayoutOrientation::kVertical,
                                       views::MinimumFlexSizeRule::kPreferred,
                                       views::MaximumFlexSizeRule::kUnbounded))
          // If the nudge has an image and no title, vertically center the text.
          .SetMainAxisAlignment(has_image && !has_title
                                    ? views::LayoutAlignment::kCenter
                                    : views::LayoutAlignment::kStart)
          .Build());

  auto label_width = nudge_data.image_model.IsEmpty()
                         ? kNudgeLabelWidth_NudgeWithoutLeadingImage
                         : kNudgeLabelWidth_NudgeWithLeadingImage;

  if (has_title) {
    auto* title_label = text_container->AddChildView(
        views::Builder<views::Label>()
            .SetID(VIEW_ID_SYSTEM_NUDGE_TITLE_LABEL)
            .SetText(nudge_data.title_text)
            .SetTooltipText(nudge_data.title_text)
            .SetHorizontalAlignment(gfx::ALIGN_LEFT)
            .SetEnabledColorId(cros_tokens::kCrosSysOnSurface)
            .SetAutoColorReadabilityEnabled(false)
            .SetSubpixelRenderingEnabled(false)
            .SetFontList(TypographyProvider::Get()->ResolveTypographyToken(
                TypographyToken::kCrosButton1))
            .SetMaximumWidthSingleLine(label_width)
            .Build());

    AddPaddingView(text_container, title_label->width(), kTitleBottomPadding);
  }

  auto* body_label = text_container->AddChildView(
      views::Builder<views::Label>()
          .SetID(VIEW_ID_SYSTEM_NUDGE_BODY_LABEL)
          .SetText(nudge_data.body_text)
          .SetTooltipText(nudge_data.body_text)
          .SetHorizontalAlignment(gfx::ALIGN_LEFT)
          .SetEnabledColorId(cros_tokens::kCrosSysOnSurface)
          .SetAutoColorReadabilityEnabled(false)
          .SetSubpixelRenderingEnabled(false)
          .SetFontList(TypographyProvider::Get()->ResolveTypographyToken(
              TypographyToken::kCrosAnnotation1))
          .SetMultiLine(true)
          .SetMaxLines(kBodyLabelMaxLines)
          .SizeToFit(label_width)
          .Build());

  // TODO(b/302368860): Add support for a view to display keyboard shortcuts in
  // the same style as the launcher and the new keyboard shortcut app.
  if (!nudge_data.keyboard_codes.empty()) {
    AddPaddingView(text_container, image_and_text_container->width(),
                   kTitleBottomPadding);

    text_container
        ->AddChildView(
            std::make_unique<KeyboardShortcutView>(nudge_data.keyboard_codes))
        ->SetID(VIEW_ID_SYSTEM_NUDGE_SHORTCUT_VIEW);
  }

  // Return early if there are no buttons.
  if (nudge_data.primary_button_text.empty()) {
    CHECK(nudge_data.secondary_button_text.empty());

    // Update nudge margins and body label max width if nudge only has text.
    if (nudge_is_text_only) {
      SetInteriorMargin(kTextOnlyNudgeInteriorMargin);
      // `SizeToFit` is reset to zero so a maximum width can be set.
      body_label->SizeToFit(0);
      body_label->SetMaximumWidth(kNudgeLabelWidth_TextOnlyNudge);
    }
    return;
  }

  // Add top padding for the buttons row.
  AddPaddingView(this, image_and_text_container->width(),
                 kButtonContainerTopPadding);

  auto* buttons_container = AddChildView(
      views::Builder<views::FlexLayoutView>()
          .SetMainAxisAlignment(views::LayoutAlignment::kEnd)
          .SetInteriorMargin(
              has_close_button
                  ? kNudgeWithCloseButton_ButtonContainerInteriorMargin
                  : gfx::Insets())
          .SetIgnoreDefaultMainAxisMargins(true)
          .SetCollapseMargins(true)
          .Build());
  buttons_container->SetDefault(views::kMarginsKey, kButtonsMargins);

  std::vector<views::View*> focusable_children;
  focusable_children.push_back(buttons_container->AddChildView(
      views::Builder<PillButton>()
          .SetID(VIEW_ID_SYSTEM_NUDGE_PRIMARY_BUTTON)
          .SetGroup(kFocusableViewsGroupId)
          .SetCallback(std::move(nudge_data.primary_button_callback))
          .SetText(nudge_data.primary_button_text)
          .SetTooltipText(nudge_data.primary_button_text)
          .SetPillButtonType(PillButton::Type::kPrimaryWithoutIcon)
          .SetFocusBehavior(views::View::FocusBehavior::ALWAYS)
          .SetProperty(views::kElementIdentifierKey, kPrimaryButtonIdForTesting)
          .Build()));

  if (!nudge_data.secondary_button_text.empty()) {
    focusable_children.push_back(buttons_container->AddChildViewAt(
        views::Builder<PillButton>()
            .SetID(VIEW_ID_SYSTEM_NUDGE_SECONDARY_BUTTON)
            .SetGroup(kFocusableViewsGroupId)
            .SetCallback(std::move(nudge_data.secondary_button_callback))
            .SetText(nudge_data.secondary_button_text)
            .SetTooltipText(nudge_data.secondary_button_text)
            .SetPillButtonType(PillButton::Type::kSecondaryWithoutIcon)
            .SetFocusBehavior(views::View::FocusBehavior::ALWAYS)
            .SetProperty(views::kElementIdentifierKey,
                         kSecondaryButtonIdForTesting)
            .Build(),
        0));
  }

  focusable_children_observer_ = std::make_unique<FocusableChildrenObserver>(
      std::move(focusable_children),
      base::BindRepeating(&SystemNudgeView::HandleOnChildFocusStateChanged,
                          // Unretained is safe because `this` outlives the
                          // `FocusableChildrenObserver`.
                          base::Unretained(this)));
}

SystemNudgeView::~SystemNudgeView() {
  auto* widget = GetWidget();
  if (widget && widget->HasObserver(this)) {
    widget->RemoveObserver(this);
  }
}

void SystemNudgeView::AddedToWidget() {
  GetWidget()->AddObserver(this);

  // Attach the shadow at the bottom of the widget layer.
  auto* shadow_layer = shadow_->GetLayer();
  auto* widget_layer = GetWidget()->GetLayer();
  widget_layer->Add(shadow_layer);
  widget_layer->StackAtBottom(shadow_layer);
}

void SystemNudgeView::RemovedFromWidget() {
  auto* widget = GetWidget();
  if (widget && widget->HasObserver(this)) {
    widget->RemoveObserver(this);
  }
}

void SystemNudgeView::OnMouseEntered(const ui::MouseEvent& event) {
  HandleOnMouseHovered(/*mouse_entered=*/true);
}

void SystemNudgeView::OnMouseExited(const ui::MouseEvent& event) {
  HandleOnMouseHovered(/*mouse_entered=*/false);
}

void SystemNudgeView::OnWidgetBoundsChanged(views::Widget* widget,
                                            const gfx::Rect& new_bounds) {
  // `shadow_` should have the same bounds as the view's layer.
  shadow_->SetContentBounds(layer()->bounds());

  if (anchor_view_tracker_ && anchor_view_tracker_->view() &&
      is_corner_anchored_) {
    SetNudgeRoundedCornerRadius(CalculatePointyAnchoredNudgeCorners(
        /*nudge_view=*/this, anchor_view_tracker_->view()));
  }
}

void SystemNudgeView::OnWidgetDestroying(views::Widget* widget) {
  if (widget && widget->HasObserver(this)) {
    widget->RemoveObserver(this);
  }
}

void SystemNudgeView::HandleOnChildFocusStateChanged(bool focus_entered) {
  hover_or_focus_changed_callback_.Run(IsHoveredOrChildHasFocus());
}

void SystemNudgeView::HandleOnMouseHovered(bool mouse_entered) {
  if (close_button_) {
    close_button_->SetVisible(mouse_entered);
  }

  if (hover_changed_callback_) {
    hover_changed_callback_.Run(mouse_entered);
  }
  hover_or_focus_changed_callback_.Run(IsHoveredOrChildHasFocus());
}

bool SystemNudgeView::IsHoveredOrChildHasFocus() {
  views::View::Views focusable_views;
  GetViewsInGroup(kFocusableViewsGroupId, &focusable_views);
  bool child_has_focus =
      std::find(focusable_views.begin(), focusable_views.end(),
                GetWidget()->GetFocusManager()->GetFocusedView()) !=
      focusable_views.end();

  return IsMouseHovered() || child_has_focus;
}

void SystemNudgeView::SetNudgeRoundedCornerRadius(
    const gfx::RoundedCornersF& rounded_corners) {
  layer()->SetRoundedCornerRadius(rounded_corners);
  SetBorder(std::make_unique<views::HighlightBorder>(
      rounded_corners, views::HighlightBorder::Type::kHighlightBorderOnShadow));
  shadow_->SetRoundedCorners(rounded_corners);
}

BEGIN_METADATA(SystemNudgeView)
END_METADATA

}  // namespace ash