chromium/ash/system/focus_mode/focus_mode_chip_carousel.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/focus_mode/focus_mode_chip_carousel.h"

#include "ash/api/tasks/tasks_types.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "base/containers/adapters.h"
#include "base/i18n/rtl.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/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/layer.h"
#include "ui/events/gesture_event_details.h"
#include "ui/gfx/geometry/linear_gradient.h"
#include "ui/gfx/geometry/size.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/view_utils.h"

namespace ash {

namespace {

constexpr auto kCarouselInsets = gfx::Insets::TLBR(16, 0, 0, 0);
constexpr int kChipSpaceBetween = 8;
constexpr int kChipHeight = 32;
constexpr int kChipMaxWidth = 216;
constexpr auto kChipInsets = gfx::Insets::VH(0, 12);
constexpr int kChipCornerRadius = 16;
constexpr size_t kMaxTasks = 5;
constexpr int kChevronSize = 16;
constexpr int kOverflowButtonWidth = 28;
constexpr float kGradientWidth = 16;

// How far from the left the scrolled-to chip should be to ensure some of the
// previous chip is visible.
constexpr int kFirstChipOffsetX =
    kOverflowButtonWidth + kGradientWidth + kChipSpaceBetween;

void SetupChip(views::LabelButton* chip, bool first) {
  chip->SetHorizontalAlignment(gfx::ALIGN_CENTER);
  chip->SetBorder(views::CreatePaddedBorder(
      views::CreateThemedRoundedRectBorder(1, kChipHeight,
                                           cros_tokens::kCrosSysSeparator),
      kChipInsets));
  // Add a border to space out chips on all chips but the first.
  chip->SetProperty(views::kMarginsKey,
                    gfx::Insets::TLBR(0, first ? 0 : kChipSpaceBetween, 0, 0));
  chip->SetLabelStyle(views::style::STYLE_BODY_3_MEDIUM);
  chip->SetMinSize(gfx::Size(0, kChipHeight));
  chip->SetMaxSize(gfx::Size(kChipMaxWidth, kChipHeight));
  views::FocusRing::Get(chip)->SetColorId(cros_tokens::kCrosSysFocusRing);
  // Remove the padding between the focus ring and the `chip`.
  views::InstallRoundRectHighlightPathGenerator(chip, gfx::Insets(4),
                                                kChipCornerRadius);
  chip->SetNotifyEnterExitOnChild(true);
  chip->SetTooltipText(chip->GetText());

  views::ViewAccessibility& view_accessibility = chip->GetViewAccessibility();
  view_accessibility.SetName(chip->GetText());
  // Set the list item role with a description to let the users know that they
  // can press this item as a button.
  view_accessibility.SetRole(
      ax::mojom::Role::kListItem,
      l10n_util::GetStringUTF16(IDS_ASH_A11Y_ROLE_BUTTON));
}

void SetupOverflowIcon(views::ImageButton* overflow_icon, bool left) {
  overflow_icon->SetImageModel(
      views::Button::STATE_NORMAL,
      ui::ImageModel::FromVectorIcon(left ? kCaretLeftIcon : kCaretRightIcon,
                                     cros_tokens::kCrosSysOnSurface,
                                     kChevronSize));
  overflow_icon->SetTooltipText(left ? u"Scroll Left" : u"Scroll Right");
  overflow_icon->SetPreferredSize(gfx::Size(kOverflowButtonWidth, kChipHeight));
  overflow_icon->SetImageVerticalAlignment(views::ImageButton::ALIGN_MIDDLE);
  overflow_icon->SetImageHorizontalAlignment(
      left ? views::ImageButton::ALIGN_LEFT : views::ImageButton::ALIGN_RIGHT);
  overflow_icon->SetPaintToLayer();
  overflow_icon->SetFocusBehavior(views::View::FocusBehavior::NEVER);
  overflow_icon->layer()->SetFillsBoundsOpaquely(false);
}

bool IsVerticalScrollGesture(const ui::Event& event) {
  if (!event.IsGestureEvent()) {
    return false;
  }

  auto is_vertical = [](float x_offset, float y_offset) -> bool {
    return std::fabs(x_offset) <= std::fabs(y_offset);
  };

  const auto& details = event.AsGestureEvent()->details();
  return (event.type() == ui::EventType::kGestureScrollUpdate &&
          is_vertical(details.scroll_x(), details.scroll_y())) ||
         (event.type() == ui::EventType::kGestureScrollBegin &&
          is_vertical(details.scroll_x_hint(), details.scroll_y_hint())) ||
         (event.type() == ui::EventType::kScrollFlingStart &&
          is_vertical(details.velocity_x(), details.velocity_y()));
}

class ChipCarouselScrollView : public views::ScrollView {
  METADATA_HEADER(ChipCarouselScrollView, views::ScrollView)

 public:
  explicit ChipCarouselScrollView(ScrollWithLayers scroll_with_layers)
      : views::ScrollView(scroll_with_layers) {}

  // views::ScrollView:
  bool OnMouseWheel(const ui::MouseWheelEvent& event) override {
    // We want this scroll view to only handle the horizontal scroll events on
    // it; if the user scrolls on it vertically, we want the outer scroll view
    // to handle it.
    return horizontal_scroll_bar()->OnScroll(event.x_offset(), 0);
  }

  // views::View:
  bool CanAcceptEvent(const ui::Event& event) override {
    // The vertical scroll gesture event should be handled by the outer scroll
    // view instead of this view.
    return views::ScrollView::CanAcceptEvent(event) &&
           !IsVerticalScrollGesture(event);
  }
};
BEGIN_METADATA(ChipCarouselScrollView)
END_METADATA

}  // namespace

// `on_chip_pressed` will be called when a task chip is clicked, containing a
// task.
FocusModeChipCarousel::FocusModeChipCarousel(
    ChipPressedCallback on_chip_pressed)
    : on_chip_pressed_(std::move(on_chip_pressed)) {
  SetProperty(views::kBoxLayoutFlexKey, views::BoxLayoutFlexSpecification());
  SetBorder(views::CreateEmptyBorder(kCarouselInsets));
  SetOrientation(views::BoxLayout::Orientation::kHorizontal);
  SetNotifyEnterExitOnChild(true);

  scroll_view_ = AddChildView(std::make_unique<ChipCarouselScrollView>(
      views::ScrollView::ScrollWithLayers::kEnabled));
  scroll_view_->SetHorizontalScrollBarMode(
      views::ScrollView::ScrollBarMode::kHiddenButEnabled);
  scroll_view_->SetVerticalScrollBarMode(
      views::ScrollView::ScrollBarMode::kDisabled);
  scroll_view_->SetDrawOverflowIndicator(false);
  scroll_view_->SetPaintToLayer();
  scroll_view_->SetBackgroundColor(std::nullopt);

  scroll_contents_ =
      scroll_view_->SetContents(std::make_unique<views::FlexLayoutView>());
  scroll_contents_->SetOrientation(views::LayoutOrientation::kHorizontal);
  scroll_contents_->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(views::MinimumFlexSizeRule::kPreferred,
                               views::MaximumFlexSizeRule::kPreferred));

  views::ViewAccessibility& scroll_contents_view_accessibility =
      scroll_contents_->GetViewAccessibility();
  scroll_contents_view_accessibility.SetRole(ax::mojom::Role::kList);
  scroll_contents_view_accessibility.SetName(l10n_util::GetStringUTF16(
      IDS_ASH_STATUS_TRAY_FOCUS_MODE_TASK_SUGGESTED_TASKS));

  left_overflow_icon_ = AddChildView(std::make_unique<views::ImageButton>(
      base::BindRepeating(&FocusModeChipCarousel::OnChevronPressed,
                          base::Unretained(this), /*left=*/true)));
  SetupOverflowIcon(left_overflow_icon_, /*left=*/true);
  right_overflow_icon_ = AddChildView(std::make_unique<views::ImageButton>(
      base::BindRepeating(&FocusModeChipCarousel::OnChevronPressed,
                          base::Unretained(this), /*left=*/false)));
  SetupOverflowIcon(right_overflow_icon_, /*left=*/false);

  on_contents_scrolled_subscription_ =
      scroll_view_->AddContentsScrolledCallback(base::BindRepeating(
          &FocusModeChipCarousel::UpdateGradient, base::Unretained(this)));
  on_contents_scroll_ended_subscription_ =
      scroll_view_->AddContentsScrollEndedCallback(base::BindRepeating(
          &FocusModeChipCarousel::UpdateGradient, base::Unretained(this)));
}

FocusModeChipCarousel::~FocusModeChipCarousel() = default;

void FocusModeChipCarousel::Layout(PassKey) {
  if (!GetVisible()) {
    return;
  }

  LayoutSuperclass<views::View>(this);
  scroll_contents_->SizeToPreferredSize();

  const gfx::Rect contents_bounds = GetContentsBounds();
  const int x = contents_bounds.x();
  const int y = contents_bounds.y();
  const int h = contents_bounds.height();

  left_overflow_icon_->SetBoundsRect(gfx::Rect(x, y, kOverflowButtonWidth, h));
  right_overflow_icon_->SetBoundsRect(
      gfx::Rect(contents_bounds.right() - kOverflowButtonWidth, y,
                kOverflowButtonWidth, h));

  UpdateGradient();
}

void FocusModeChipCarousel::OnMouseEntered(const ui::MouseEvent& event) {
  UpdateGradient();
}

void FocusModeChipCarousel::OnMouseExited(const ui::MouseEvent& event) {
  UpdateGradient();
}

void FocusModeChipCarousel::SetTasks(const std::vector<FocusModeTask>& tasks) {
  scroll_contents_->RemoveAllChildViews();
  if (tasks.empty()) {
    return;
  }

  // Populate a maximum of `kMaxTasks` tasks.
  const size_t num_tasks = std::min(tasks.size(), kMaxTasks);
  for (size_t i = 0; i < num_tasks; i++) {
    // Skip empty task.
    if (tasks[i].title.empty()) {
      continue;
    }
    views::LabelButton* chip =
        scroll_contents_->AddChildView(std::make_unique<views::LabelButton>(
            base::BindRepeating(on_chip_pressed_, tasks[i]),
            base::UTF8ToUTF16(tasks[i].title)));
    SetupChip(chip, /*first=*/(i == 0));
  }

  // After adding the child views to the contents of the scroll view, we need to
  // manually call the function to update the bounds, so that the horizontal
  // scroll bar can have a non-zero `max_pos_` to allow the chip carousel to
  // scroll horizontally. See b/346877741.
  scroll_view_->contents()->SizeToPreferredSize();

  // Scroll back to the beginning after repopulating the carousel.
  scroll_view_->ScrollToOffset(gfx::PointF(0, 0));
}

void FocusModeChipCarousel::UpdateGradient() {
  const gfx::Rect visible_rect = scroll_view_->GetVisibleRect();
  // Show the left gradient if the scroll view is not scrolled to the left.
  const bool show_left_gradient = visible_rect.x() > 0;
  // Show the right gradient if the scroll view is not scrolled to the right.
  const bool show_right_gradient =
      visible_rect.right() < scroll_view_->contents()->bounds().right();
  const bool hovered = IsMouseHovered();

  left_overflow_icon_->SetVisible(show_left_gradient && hovered);
  right_overflow_icon_->SetVisible(show_right_gradient && hovered);

  // If no gradient is needed, remove the gradient mask.
  if (scroll_view_->contents()->bounds().IsEmpty() ||
      scroll_view_->bounds().IsEmpty() ||
      (!show_left_gradient && !show_right_gradient)) {
    RemoveGradient();
    return;
  }

  // Horizontal linear gradient, from left to right.
  gfx::LinearGradient gradient_mask(/*angle=*/0);

  // We want a completely transparent section at the beginning for the chevron,
  // and then a gradient section. Only add extra space for the chevrons if the
  // carousel is hovered, otherwise the chevrons won't be shown.
  const float chevron_space = hovered ? kOverflowButtonWidth : 0;
  const float gradient_start_position =
      chevron_space / scroll_view_->bounds().width();
  const float gradient_end_position =
      (chevron_space + kGradientWidth) / scroll_view_->bounds().width();

  // Left fade in section. Gradients don't account for RTL like other `Views`
  // coordinates do, so we need to flip to account for RTL ourselves.
  if (base::i18n::IsRTL() ? show_right_gradient : show_left_gradient) {
    gradient_mask.AddStep(/*fraction=*/0, /*alpha=*/0);
    if (hovered) {
      gradient_mask.AddStep(gradient_start_position, 0);
    }
    gradient_mask.AddStep(gradient_end_position, 255);
  }

  // Right fade out section.
  if (base::i18n::IsRTL() ? show_left_gradient : show_right_gradient) {
    gradient_mask.AddStep(/*fraction=*/(1 - gradient_end_position),
                          /*alpha=*/255);
    if (hovered) {
      gradient_mask.AddStep((1 - gradient_start_position), 0);
    }
    gradient_mask.AddStep(1, 0);
  }

  if (scroll_view_->layer()->gradient_mask() != gradient_mask) {
    scroll_view_->layer()->SetGradientMask(gradient_mask);
  }
}

void FocusModeChipCarousel::RemoveGradient() {
  if (scroll_view_->layer()->HasGradientMask()) {
    scroll_view_->layer()->SetGradientMask(gfx::LinearGradient::GetEmpty());
  }
}

void FocusModeChipCarousel::OnChevronPressed(bool left) {
  const int align_point_x =
      scroll_view_->GetVisibleRect().x() + kFirstChipOffsetX;

  // Pressing the chevrons should position the next chip with some offset
  // `kFirstChipOffsetX` from the left so you can still see the previous and
  // next chips. When scrolling right, check from the start and scroll to the
  // first chip whose origin is past the desired position. When scrolling left,
  // start from the end and scroll to the first chip whose origin is to the left
  // of the desired position.
  View::Views children = scroll_contents_->GetChildrenInZOrder();
  for (size_t i = 0; i < children.size(); i++) {
    views::View* chip_view = children[left ? (children.size() - 1) - i : i];
    const int chip_left =
        views::View::ConvertRectToTarget(chip_view, scroll_contents_,
                                         chip_view->GetLocalBounds())
            .x();
    if (left ? chip_left < align_point_x : chip_left > align_point_x) {
      ScrollToChip(chip_view);
      return;
    }
  }

  // Pressing a chevron should always result in a scroll.
  NOTREACHED();
}

void FocusModeChipCarousel::ScrollToChip(views::View* chip) {
  const gfx::Rect viewport = scroll_view_->GetVisibleRect();
  const int chip_left = views::View::ConvertRectToTarget(chip, scroll_contents_,
                                                         chip->GetLocalBounds())
                            .x();
  const int scroll_offset = chip_left - viewport.x() - kFirstChipOffsetX;

  // Don't scroll past the end of `scroll_contents_`.
  int scroll_total;
  if (scroll_offset < 0) {
    // The scroll offset to scroll all the way to the left.
    const int min_scroll =
        scroll_view_->contents()->bounds().x() - viewport.x();

    scroll_total = std::max(scroll_offset, min_scroll);
  } else {
    // The scroll offset to scroll all the way to the right.
    const int max_scroll =
        scroll_view_->contents()->bounds().right() - viewport.right();

    scroll_total = std::min(scroll_offset, max_scroll);
  }

  scroll_view_->ScrollByOffset(gfx::PointF(scroll_total, 0));
  SchedulePaint();
}

bool FocusModeChipCarousel::HasTasks() const {
  return !scroll_contents_->GetChildrenInZOrder().empty();
}

int FocusModeChipCarousel::GetTaskCountForTesting() const {
  return scroll_contents_->GetChildrenInZOrder().size();
}

views::ScrollView* FocusModeChipCarousel::GetScrollViewForTesting() const {
  return views::AsViewClass<views::ScrollView>(scroll_view_);
}

BEGIN_METADATA(FocusModeChipCarousel)
END_METADATA

}  // namespace ash