chromium/ash/picker/views/picker_list_item_view.cc

// Copyright 2024 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/picker/views/picker_list_item_view.h"

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

#include "ash/ash_element_identifiers.h"
#include "ash/bubble/bubble_utils.h"
#include "ash/picker/views/picker_badge_view.h"
#include "ash/picker/views/picker_item_view.h"
#include "ash/picker/views/picker_preview_bubble.h"
#include "ash/picker/views/picker_preview_bubble_controller.h"
#include "ash/picker/views/picker_preview_metadata.h"
#include "ash/picker/views/picker_shortcut_hint_view.h"
#include "ash/public/cpp/holding_space/holding_space_image.h"
#include "ash/public/cpp/image_util.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/style_util.h"
#include "ash/style/typography.h"
#include "base/files/file.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/location.h"
#include "base/strings/string_util.h"
#include "base/task/task_traits.h"
#include "base/task/thread_pool.h"
#include "third_party/skia/include/core/SkPath.h"
#include "third_party/skia/include/core/SkScalar.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/color/color_id.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/point.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/size.h"
#include "ui/gfx/geometry/skia_conversions.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/border.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/layout/layout_manager.h"
#include "ui/views/layout/layout_types.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/view_utils.h"

namespace ash {
namespace {

constexpr auto kBorderInsetsWithoutBadge = gfx::Insets::TLBR(8, 16, 8, 16);
constexpr auto kBorderInsetsWithBadge = gfx::Insets::TLBR(8, 16, 8, 12);

constexpr gfx::Size kLeadingIconSizeDip(20, 20);
constexpr int kImageDisplayHeight = 64;
constexpr int kImageRadius = 8;
constexpr auto kLeadingIconRightPadding = gfx::Insets::TLBR(0, 0, 0, 16);
constexpr auto kBadgeLeftPadding = gfx::Insets::TLBR(0, 8, 0, 0);

// An ImageView that can optionally be masked with a circle.
class LeadingIconImageView : public views::ImageView {
  METADATA_HEADER(LeadingIconImageView, views::ImageView)

 public:
  LeadingIconImageView() = default;
  LeadingIconImageView(const LeadingIconImageView&) = delete;
  LeadingIconImageView& operator=(const LeadingIconImageView&) = delete;

  void SetCircularMaskEnabled(bool enabled) {
    if (enabled) {
      const gfx::Rect& bounds = GetImageBounds();

      // Calculate the radius of the circle based on the minimum of width and
      // height in case the icon isn't square.
      SkPath mask;
      mask.addCircle(bounds.x() + bounds.width() / 2,
                     bounds.y() + bounds.height() / 2,
                     std::min(bounds.width(), bounds.height()) / 2);
      SetClipPath(mask);
    } else {
      SetClipPath(SkPath());
    }
  }
};

BEGIN_METADATA(LeadingIconImageView)
END_METADATA

BEGIN_VIEW_BUILDER(/*no export*/, LeadingIconImageView, views::ImageView)
END_VIEW_BUILDER

}  // namespace
}  // namespace ash

DEFINE_VIEW_BUILDER(/* no export */, ash::LeadingIconImageView)

namespace ash {

PickerListItemView::PickerListItemView(SelectItemCallback select_item_callback)
    : PickerItemView(std::move(select_item_callback),
                     FocusIndicatorStyle::kFocusBar) {
  // This view only contains one child for the moment, but treat this as a
  // full-width vertical list.
  SetLayoutManager(
      std::make_unique<views::BoxLayout>(views::LayoutOrientation::kVertical));

  // `item_contents` is used to group child views that should not receive
  // events.
  // TODO: Align the leading icon to the top of the item.
  auto* item_contents =
      AddChildView(views::Builder<views::BoxLayoutView>()
                       .SetOrientation(views::LayoutOrientation::kHorizontal)
                       .SetCrossAxisAlignment(views::LayoutAlignment::kCenter)
                       .SetCanProcessEventsWithinSubtree(false)
                       .Build());

  // The leading icon should always be preferred size.
  leading_icon_view_ = item_contents->AddChildView(
      views::Builder<LeadingIconImageView>()
          .SetPreferredSize(kLeadingIconSizeDip)
          .SetCanProcessEventsWithinSubtree(false)
          .SetProperty(views::kMarginsKey, kLeadingIconRightPadding)
          .Build());

  // The main container should use the remaining horizontal space.
  // Shrink to zero to allow the main contents to be elided.
  auto* main_container = item_contents->AddChildView(
      views::Builder<views::BoxLayoutView>()
          .SetOrientation(views::LayoutOrientation::kVertical)
          .SetCrossAxisAlignment(views::BoxLayout::CrossAxisAlignment::kStart)
          .Build());
  item_contents->SetFlexForView(main_container, 1);
  primary_container_ = main_container->AddChildView(
      views::Builder<views::View>().SetUseDefaultFillLayout(true).Build());
  secondary_container_ = main_container->AddChildView(
      views::Builder<views::View>().SetUseDefaultFillLayout(true).Build());

  shortcut_hint_container_ = item_contents->AddChildView(
      views::Builder<views::View>().SetUseDefaultFillLayout(true).Build());

  // Trailing badge should always be preferred size.
  trailing_badge_ = item_contents->AddChildView(
      views::Builder<PickerBadgeView>()
          .SetProperty(views::kMarginsKey, kBadgeLeftPadding)
          .SetVisible(false)
          .Build());
  SetBadgeVisible(false);

  SetProperty(views::kElementIdentifierKey,
              kPickerSearchResultsListItemElementId);
}

PickerListItemView::~PickerListItemView() {
  if (preview_bubble_controller_ != nullptr) {
    preview_bubble_controller_->CloseBubble();
  }
}

void PickerListItemView::SetItemState(ItemState item_state) {
  PickerItemView::SetItemState(item_state);
  if (GetItemState() == ItemState::kPseudoFocused) {
    ShowPreview();
    shortcut_hint_container_->SetVisible(false);
  } else {
    HidePreview();
    shortcut_hint_container_->SetVisible(true);
  }
}

void PickerListItemView::SetPrimaryText(const std::u16string& primary_text) {
  primary_container_->RemoveAllChildViews();
  primary_label_ = primary_container_->AddChildView(
      views::Builder<views::Label>(
          bubble_utils::CreateLabel(TypographyToken::kCrosBody2, primary_text,
                                    cros_tokens::kCrosSysOnSurface))
          .SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT)
          .SetElideBehavior(gfx::ElideBehavior::ELIDE_TAIL)
          .Build());
  UpdateAccessibleName();
}

void PickerListItemView::SetPrimaryImage(const ui::ImageModel& primary_image,
                                         int available_width) {
  primary_label_ = nullptr;
  primary_container_->RemoveAllChildViews();
  auto* image_view =
      primary_container_->AddChildView(std::make_unique<views::ImageView>(
          ui::ImageModel::FromImageSkia(image_util::ResizeAndCropImage(
              primary_image.Rasterize(GetColorProvider()),
              gfx::Size(available_width - kBorderInsetsWithoutBadge.width() -
                            kLeadingIconSizeDip.width() -
                            kLeadingIconRightPadding.right(),
                        kImageDisplayHeight)))));
  image_view->SetCanProcessEventsWithinSubtree(false);
  const gfx::Size cropped_size = image_view->GetImageModel().Size();
  if (cropped_size.height() > 0) {
    SkPath path;
    path.addRoundRect(gfx::RectToSkRect(gfx::Rect(gfx::Point(), cropped_size)),
                      SkIntToScalar(kImageRadius), SkIntToScalar(kImageRadius));
    image_view->SetClipPath(path);
  }
  UpdateAccessibleName();
}

void PickerListItemView::SetLeadingIcon(const ui::ImageModel& icon,
                                        std::optional<gfx::Size> icon_size) {
  leading_icon_view_->SetImage(icon);
  leading_icon_view_->SetImageSize(icon_size.value_or(kLeadingIconSizeDip));
}

void PickerListItemView::SetSecondaryText(
    const std::u16string& secondary_text) {
  secondary_label_ = nullptr;
  secondary_container_->RemoveAllChildViews();
  if (secondary_text.empty()) {
    UpdateAccessibleName();
    return;
  }
  secondary_label_ = secondary_container_->AddChildView(
      views::Builder<views::Label>(
          bubble_utils::CreateLabel(TypographyToken::kCrosAnnotation2,
                                    secondary_text,
                                    cros_tokens::kCrosSysOnSurfaceVariant))
          .SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT)
          .SetElideBehavior(gfx::ElideBehavior::ELIDE_TAIL)
          .Build());
  UpdateAccessibleName();
}

void PickerListItemView::SetShortcutHintView(
    std::unique_ptr<PickerShortcutHintView> shortcut_hint_view) {
  shortcut_hint_view_ = nullptr;
  shortcut_hint_container_->RemoveAllChildViews();
  shortcut_hint_view_ =
      shortcut_hint_container_->AddChildView(std::move(shortcut_hint_view));
}

void PickerListItemView::SetBadgeAction(PickerActionType action) {
  switch (action) {
    case PickerActionType::kDo:
      trailing_badge_->SetText(u"");
      break;
    case PickerActionType::kInsert:
      trailing_badge_->SetText(
          l10n_util::GetStringUTF16(IDS_PICKER_INSERT_RESULT_BADGE_LABEL));
      break;
    case PickerActionType::kOpen:
      trailing_badge_->SetText(
          l10n_util::GetStringUTF16(IDS_PICKER_OPEN_RESULT_BADGE_LABEL));
      break;
    case PickerActionType::kCreate:
      trailing_badge_->SetText(
          l10n_util::GetStringUTF16(IDS_PICKER_CREATE_RESULT_BADGE_LABEL));
      break;
  }
  badge_action_ = action;
  UpdateAccessibleName();
}

void PickerListItemView::SetBadgeVisible(bool visible) {
  if (primary_container_ != nullptr &&
      !primary_container_->children().empty() &&
      views::IsViewClass<views::ImageView>(
          primary_container_->children().front().get())) {
    // Badge should not be visible if the list item has a primary image.
    return;
  }

  trailing_badge_->SetVisible(visible);

  if (visible) {
    SetBorder(views::CreateEmptyBorder(kBorderInsetsWithBadge));
  } else {
    SetBorder(views::CreateEmptyBorder(kBorderInsetsWithoutBadge));
  }
}

void PickerListItemView::SetPreview(
    PickerPreviewBubbleController* preview_bubble_controller,
    FileInfoResolver get_file_info,
    const base::FilePath& file_path,
    AsyncBitmapResolver async_bitmap_resolver,
    bool update_icon) {
  if (preview_bubble_controller_ != nullptr) {
    preview_bubble_controller_->CloseBubble();
  }

  async_preview_image_ = std::make_unique<ash::HoldingSpaceImage>(
      PickerPreviewBubbleView::kPreviewImageSize, file_path,
      async_bitmap_resolver);
  file_path_ = file_path;
  preview_bubble_controller_ = preview_bubble_controller;

  // Can be null in tests.
  if (!get_file_info.is_null()) {
    base::ThreadPool::PostTaskAndReplyWithResult(
        FROM_HERE, {base::MayBlock(), base::TaskPriority::USER_VISIBLE},
        std::move(get_file_info),
        base::BindOnce(&PickerListItemView::OnFileInfoResolved,
                       weak_ptr_factory_.GetWeakPtr()));
  }

  if (update_icon) {
    // base::Unretained is safe here since `async_icon_subscription_` is a
    // member. During destruction, `async_icon_subscription_` will be destroyed
    // before the other members, so the callback is guaranteed to be safe.
    async_preview_icon_ = std::make_unique<ash::HoldingSpaceImage>(
        kLeadingIconSizeDip, file_path, std::move(async_bitmap_resolver));
    async_icon_subscription_ = async_preview_icon_->AddImageSkiaChangedCallback(
        base::BindRepeating(&PickerListItemView::UpdateIconWithPreview,
                            base::Unretained(this)));
    UpdateIconWithPreview();
  }
}

void PickerListItemView::OnMouseEntered(const ui::MouseEvent& event) {
  PickerItemView::OnMouseEntered(event);
  ShowPreview();
}

void PickerListItemView::OnMouseExited(const ui::MouseEvent& event) {
  PickerItemView::OnMouseExited(event);
  HidePreview();
}

std::u16string PickerListItemView::GetPrimaryTextForTesting() const {
  return primary_label_ == nullptr ? u"" : primary_label_->GetText();
}

ui::ImageModel PickerListItemView::GetPrimaryImageForTesting() const {
  if (primary_container_->children().empty()) {
    return ui::ImageModel();
  }
  if (const auto* image = views::AsViewClass<views::ImageView>(
          primary_container_->children().front().get())) {
    return image->GetImageModel();
  }
  return ui::ImageModel();
}

std::u16string_view PickerListItemView::GetSecondaryTextForTesting() const {
  if (secondary_label_ == nullptr) {
    return base::EmptyString16();
  }
  return secondary_label_->GetText();
}

void PickerListItemView::UpdateIconWithPreview() {
  views::AsViewClass<LeadingIconImageView>(leading_icon_view_)
      ->SetCircularMaskEnabled(true);
  SetLeadingIcon(
      ui::ImageModel::FromImageSkia(async_preview_icon_->GetImageSkia()));
}

std::u16string PickerListItemView::GetAccessibilityLabel() const {
  // TODO: b/316936418 - Get accessible name for image contents.
  const std::u16string& primary_accessibililty_label =
      primary_label_ == nullptr ? u"image contents" : primary_label_->GetText();
  std::u16string label =
      secondary_label_ == nullptr
          ? primary_accessibililty_label
          : l10n_util::GetStringFUTF16(IDS_PICKER_LIST_ITEM_ACCESSIBLE_NAME,
                                       primary_accessibililty_label,
                                       secondary_label_->GetText());
  if (shortcut_hint_view_ != nullptr) {
    label = l10n_util::GetStringFUTF16(
        IDS_PICKER_LIST_ITEM_WITH_SHORTCUT_ACCESSIBLE_NAME, label,
        shortcut_hint_view_->GetShortcutText());
  }

  switch (badge_action_) {
    case PickerActionType::kDo:
      return label;
    case PickerActionType::kInsert:
      return l10n_util::GetStringFUTF16(
          IDS_PICKER_LIST_ITEM_INSERT_ACTION_ACCESSIBLE_NAME, label);
    case PickerActionType::kOpen:
      return l10n_util::GetStringFUTF16(
          IDS_PICKER_LIST_ITEM_OPEN_ACTION_ACCESSIBLE_NAME, label);
    case PickerActionType::kCreate:
      // TODO: b/345303965 - Add internal strings for Create.
      return label;
  }
}

void PickerListItemView::UpdateAccessibleName() {
  GetViewAccessibility().SetName(GetAccessibilityLabel());
}

void PickerListItemView::OnFileInfoResolved(
    std::optional<base::File::Info> info) {
  file_info_ = std::move(info);

  std::u16string description = PickerGetFilePreviewDescription(file_info_);

  if (preview_bubble_controller_ != nullptr) {
    // Update the bubble main text if it's open.
    preview_bubble_controller_->SetBubbleMainText(description);
  }

  GetViewAccessibility().SetDescription(std::move(description));
}

void PickerListItemView::ShowPreview() {
  if (preview_bubble_controller_ == nullptr) {
    return;
  }

  std::u16string description = PickerGetFilePreviewDescription(file_info_);

  // Update the bubble main text before it becomes visible.
  preview_bubble_controller_->ShowBubbleAfterDelay(async_preview_image_.get(),
                                                   file_path_, this);
  preview_bubble_controller_->SetBubbleMainText(description);

  GetViewAccessibility().SetDescription(std::move(description));
}

void PickerListItemView::HidePreview() {
  if (preview_bubble_controller_ != nullptr) {
    preview_bubble_controller_->CloseBubble();
  }
}

BEGIN_METADATA(PickerListItemView)
END_METADATA

}  // namespace ash