chromium/ash/clipboard/views/clipboard_history_bitmap_item_view.cc

// Copyright 2020 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/clipboard/views/clipboard_history_bitmap_item_view.h"

#include "ash/clipboard/clipboard_history_item.h"
#include "ash/clipboard/views/clipboard_history_view_constants.h"
#include "ash/style/ash_color_id.h"
#include "base/callback_list.h"
#include "base/functional/bind.h"
#include "base/memory/raw_ptr.h"
#include "base/time/time.h"
#include "chromeos/constants/chromeos_features.h"
#include "third_party/skia/include/core/SkPath.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/layer_animation_observer.h"
#include "ui/compositor/scoped_layer_animation_settings.h"
#include "ui/gfx/geometry/size.h"
#include "ui/gfx/image/image.h"
#include "ui/gfx/image/image_skia.h"
#include "ui/strings/grit/ui_strings.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/view_class_properties.h"

namespace ash {

namespace {

// The duration of the fade out animation for transitioning the placeholder
// image to rendered HTML.
constexpr base::TimeDelta kFadeOutDurationMs = base::Milliseconds(60);

// The duration of the fade in animation for transitioning the placeholder image
// to rendered HTML.
constexpr base::TimeDelta kFadeInDurationMs = base::Milliseconds(200);

////////////////////////////////////////////////////////////////////////////////
// FadeImageView
// An `ImageView` which reacts to updates from its `ClipboardHistoryItem` by
// fading out the old image and fading in the new image. Used when HTML is done
// rendering. Expected to transition at most once in its lifetime.
class FadeImageView : public views::ImageView,
                      public ui::ImplicitAnimationObserver {
  METADATA_HEADER(FadeImageView, views::ImageView)

 public:
  FadeImageView(
      base::RepeatingCallback<const ClipboardHistoryItem*()> item_resolver,
      base::RepeatingClosure update_callback)
      : item_resolver_(item_resolver), update_callback_(update_callback) {
    CHECK(item_resolver_);
    CHECK(update_callback_);

    const auto* item = item_resolver_.Run();
    CHECK(item);
    // Subscribe to be notified when `item`'s display image updates.
    // `Unretained(this)` is safe because `this` owns the callback, and `item`
    // will not notify `this` of display image changes if
    // `display_image_updated_subscription_` is destroyed.
    display_image_updated_subscription_ =
        item->AddDisplayImageUpdatedCallback(base::BindRepeating(
            &FadeImageView::OnDisplayImageUpdated, base::Unretained(this)));

    SetImageFromModel();
  }

  FadeImageView(const FadeImageView& rhs) = delete;

  FadeImageView& operator=(const FadeImageView& rhs) = delete;

  ~FadeImageView() override { StopObservingImplicitAnimations(); }

  void OnDisplayImageUpdated() {
    // Fade the old image out, then swap in the new image.
    CHECK_EQ(FadeAnimationState::kNoFadeAnimation, animation_state_);
    SetPaintToLayer();
    layer()->SetFillsBoundsOpaquely(false);
    animation_state_ = FadeAnimationState::kFadeOut;

    ui::ScopedLayerAnimationSettings settings(layer()->GetAnimator());
    settings.SetTransitionDuration(kFadeOutDurationMs);
    settings.AddObserver(this);
    layer()->SetOpacity(0.0f);
  }

  // ui::ImplicitAnimationObserver:
  void OnImplicitAnimationsCompleted() override {
    switch (animation_state_) {
      case FadeAnimationState::kNoFadeAnimation:
        NOTREACHED();
      case FadeAnimationState::kFadeOut:
        CHECK_EQ(layer()->opacity(), 0.0f);
        animation_state_ = FadeAnimationState::kFadeIn;
        SetImageFromModel();
        {
          ui::ScopedLayerAnimationSettings settings(layer()->GetAnimator());
          settings.AddObserver(this);
          settings.SetTransitionDuration(kFadeInDurationMs);
          layer()->SetOpacity(1.0f);
        }
        return;
      case FadeAnimationState::kFadeIn:
        DestroyLayer();
        animation_state_ = FadeAnimationState::kNoFadeAnimation;
        return;
    }
  }

  void SetImageFromModel() {
    if (const auto* item = item_resolver_.Run()) {
      CHECK(item->display_image().has_value());
      SetImage(item->display_image().value());
    }

    // When fading in a new image, the ImageView's image has likely changed
    // sizes.
    if (animation_state_ == FadeAnimationState::kFadeIn)
      update_callback_.Run();
  }

  // The different animation states possible when transitioning from one
  // gfx::ImageSkia to the next.
  enum class FadeAnimationState {
    kNoFadeAnimation,
    kFadeOut,
    kFadeIn,
  };

  // The current animation state.
  FadeAnimationState animation_state_ = FadeAnimationState::kNoFadeAnimation;

  // Generates a *possibly null* pointer to the clipboard history item
  // represented by this image.
  base::RepeatingCallback<const ClipboardHistoryItem*()> item_resolver_;

  // Used to notify the contents view of image changes.
  base::RepeatingClosure update_callback_;

  // Subscription notified when the clipboard history item's image changes.
  base::CallbackListSubscription display_image_updated_subscription_;
};

BEGIN_METADATA(FadeImageView)
END_METADATA

}  // namespace

////////////////////////////////////////////////////////////////////////////////
// ClipboardHistoryBitmapItemView::BitmapContentsView

class ClipboardHistoryBitmapItemView::BitmapContentsView
    : public ClipboardHistoryBitmapItemView::ContentsView {
  METADATA_HEADER(BitmapContentsView, ContentsView)

 public:
  explicit BitmapContentsView(ClipboardHistoryBitmapItemView* container)
      : container_(container) {
    views::Builder<views::View>(this)
        .SetLayoutManager(std::make_unique<views::FillLayout>())
        .AddChild(views::Builder<views::ImageView>(BuildImageView())
                      .CopyAddressTo(&image_view_))
        .BuildChildren();

    if (chromeos::features::IsClipboardHistoryRefreshEnabled()) {
      // Distinguish the image from rest of the menu with a colored background.
      SetBackground(views::CreateThemedRoundedRectBackground(
          cros_tokens::kCrosSysSeparator,
          ClipboardHistoryViews::kImageBackgroundCornerRadius));
    } else {
      // Distinguish the image from rest of the menu with a border.
      views::Builder<views::View>(this)
          .AddChild(views::Builder<views::View>().SetBorder(
              views::CreateThemedRoundedRectBorder(
                  ClipboardHistoryViews::kImageBorderThickness,
                  ClipboardHistoryViews::kImageBorderCornerRadius,
                  kColorAshHairlineBorderColor)))
          .BuildChildren();
    }
  }
  BitmapContentsView(const BitmapContentsView& rhs) = delete;
  BitmapContentsView& operator=(const BitmapContentsView& rhs) = delete;
  ~BitmapContentsView() override = default;

 private:
  // ContentsView:
  SkPath GetClipPath() override {
    const SkRect contents_bounds = gfx::RectToSkRect(GetContentsBounds());
    if (!chromeos::features::IsClipboardHistoryRefreshEnabled() ||
        !is_delete_button_visible()) {
      // Create rounded corners around the contents area. Because the menu's
      // container does not cut the children's layers outside of the container's
      // bounds, we use a clip path rather than creating a layer and masking it.
      // Otherwise, it would be possible to see contents that overflowed past
      // the menu item's bounds.
      const SkScalar radius = SkIntToScalar(
          chromeos::features::IsClipboardHistoryRefreshEnabled()
              ? ClipboardHistoryViews::kImageBackgroundCornerRadius
              : ClipboardHistoryViews::kImageBorderCornerRadius);
      return SkPath::RRect(contents_bounds, radius, radius);
    }

    const auto width = contents_bounds.width();
    const auto height = contents_bounds.height();
    const auto radius = ClipboardHistoryViews::kImageBackgroundCornerRadius;

    const auto top_left = SkPoint::Make(0.f, 0.f);
    const auto bottom_left = SkPoint::Make(0.f, height);
    const auto bottom_right = SkPoint::Make(width, height);

    const auto horizontal_offset = SkPoint::Make(radius, 0.f);
    const auto vertical_offset = SkPoint::Make(0.f, radius);

    return SkPath()
        // Start just before the curve of the top-left corner.
        .moveTo(radius, 0.f)
        // Draw the top-left rounded corner.
        .arcTo(top_left, top_left + vertical_offset, radius)
        // Draw the bottom-left rounded corner and the vertical line connecting
        // it to the top-left corner.
        .arcTo(bottom_left, bottom_left + horizontal_offset, radius)
        // Draw the bottom-right rounded corner and the horizontal line
        // connecting it to the bottom-left corner.
        .arcTo(bottom_right, bottom_right - vertical_offset, radius)
        // Draw a vertical line to the start of the top-right corner's cutout.
        .lineTo(width, ClipboardHistoryViews::kCornerCutoutHeight)
        // Draw the top-right corner's cutout.
        .rCubicTo(0.f, -8.f, -6.7f, -10.f, -10.f, -10.f)
        .rLineTo(-4.f, 0.f)
        .rCubicTo(-7.7f, 0.f, -14.f, -6.3f, -14.f, -14.f)
        .rLineTo(0.f, -4.f)
        .rCubicTo(0.f, -3.3f, -2.f, -10.f, -10.f, -10.f)
        // Draw a horizontal line back to the starting point.
        .lineTo(radius, 0.f)
        .close();
  }

  gfx::Size CalculatePreferredSize(
      const views::SizeBounds& available_size) const override {
    const int preferred_width =
        ClipboardHistoryBitmapItemView::ContentsView::CalculatePreferredSize(
            available_size)
            .width();
    return gfx::Size(preferred_width,
                     ClipboardHistoryViews::kImageViewPreferredHeight);
  }

  void OnBoundsChanged(const gfx::Rect& previous_bounds) override {
    SetClipPath(GetClipPath());
    UpdateImageViewSize();
  }

  std::unique_ptr<views::ImageView> BuildImageView() {
    const auto* clipboard_history_item = container_->GetClipboardHistoryItem();
    CHECK(clipboard_history_item);
    return std::make_unique<FadeImageView>(
        // `Unretained()` is safe because `this` owns the `FadeImageView` being
        // created, and `container_` owns `this`.
        base::BindRepeating(
            &ClipboardHistoryBitmapItemView::GetClipboardHistoryItem,
            base::Unretained(container_)),
        base::BindRepeating(&BitmapContentsView::UpdateImageViewSize,
                            base::Unretained(this)));
  }

  void UpdateImageViewSize() {
    if (chromeos::features::IsClipboardHistoryRefreshEnabled() &&
        image_view_->GetImageModel() ==
            clipboard_history_util::GetHtmlPreviewPlaceholder()) {
      // The bitmap item placeholder icon's size does not depend on the
      // available space.
      image_view_->SetImageSize(
          gfx::Size(ClipboardHistoryViews::kBitmapItemPlaceholderIconSize,
                    ClipboardHistoryViews::kBitmapItemPlaceholderIconSize));
      return;
    }

    const gfx::Size image_size = image_view_->GetImage().size();
    gfx::Rect contents_bounds = GetContentsBounds();

    const float width_ratio =
        image_size.width() / float(contents_bounds.width());
    const float height_ratio =
        image_size.height() / float(contents_bounds.height());

    // Calculate `scaling_up_ratio` depending on the image type. A bitmap image
    // should fill the contents bounds while an image rendered from HTML
    // should meet at least one edge of the contents bounds.
    float scaling_up_ratio = 0.f;
    switch (container_->data_format_) {
      case ui::ClipboardInternalFormat::kPng: {
        scaling_up_ratio = std::fmin(width_ratio, height_ratio);
        break;
      }
      case ui::ClipboardInternalFormat::kHtml: {
        scaling_up_ratio = std::fmax(width_ratio, height_ratio);
        break;
      }
      default:
        NOTREACHED();
    }

    CHECK_GT(scaling_up_ratio, 0.f);

    image_view_->SetImageSize(
        gfx::Size(image_size.width() / scaling_up_ratio,
                  image_size.height() / scaling_up_ratio));
  }

  const raw_ptr<ClipboardHistoryBitmapItemView> container_;
  raw_ptr<views::ImageView> image_view_ = nullptr;
};

BEGIN_METADATA(ClipboardHistoryBitmapItemView, BitmapContentsView)
END_METADATA

////////////////////////////////////////////////////////////////////////////////
// ClipboardHistoryBitmapItemView

ClipboardHistoryBitmapItemView::ClipboardHistoryBitmapItemView(
    const base::UnguessableToken& item_id,
    const ClipboardHistory* clipboard_history,
    views::MenuItemView* container)
    : ClipboardHistoryItemView(item_id, clipboard_history, container),
      data_format_(GetClipboardHistoryItem()->main_format()) {
  SetID(clipboard_history_util::kBitmapItemView);
  switch (data_format_) {
    case ui::ClipboardInternalFormat::kHtml:
      GetViewAccessibility().SetName(
          l10n_util::GetStringUTF16(IDS_CLIPBOARD_HISTORY_MENU_HTML_IMAGE));
      break;
    case ui::ClipboardInternalFormat::kPng:
      GetViewAccessibility().SetName(
          l10n_util::GetStringUTF16(IDS_CLIPBOARD_HISTORY_MENU_PNG_IMAGE));
      break;
    default:
      NOTREACHED();
  }
}

ClipboardHistoryBitmapItemView::~ClipboardHistoryBitmapItemView() = default;

std::unique_ptr<ClipboardHistoryBitmapItemView::ContentsView>
ClipboardHistoryBitmapItemView::CreateContentsView() {
  return std::make_unique<BitmapContentsView>(this);
}

BEGIN_METADATA(ClipboardHistoryBitmapItemView)
END_METADATA

}  // namespace ash