chromium/ash/capture_mode/capture_mode_menu_group.cc

// Copyright 2021 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/capture_mode/capture_mode_menu_group.h"

#include <memory>
#include <string>
#include <utility>
#include <vector>

#include "ash/capture_mode/capture_mode_constants.h"
#include "ash/capture_mode/capture_mode_util.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/color_util.h"
#include "ash/style/style_util.h"
#include "base/memory/raw_ptr.h"
#include "base/ranges/algorithm.h"
#include "ui/accessibility/ax_enums.mojom-shared.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/gfx/paint_vector_icon.h"
#include "ui/gfx/vector_icon_types.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/controls/button/button.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/layout/box_layout.h"

namespace ash {

namespace {

constexpr auto kMenuGroupPadding = gfx::Insets::VH(8, 0);

constexpr auto kOptionPadding = gfx::Insets::VH(8, 16);
constexpr auto kIndentedOptionPadding = gfx::Insets::TLBR(8, 52, 8, 16);

constexpr auto kMenuItemPadding = gfx::Insets::VH(10, 16);
constexpr auto kIndentedMenuItemPadding = gfx::Insets::TLBR(10, 52, 10, 16);

constexpr int kSpaceBetweenMenuItem = 0;

void SetInkDropForButton(views::Button* button) {
  StyleUtil::SetUpInkDropForButton(button, gfx::Insets(),
                                   /*highlight_on_hover=*/false,
                                   /*highlight_on_focus=*/false);
  views::InstallRectHighlightPathGenerator(button);
}

// Configures the size and visibility of the given `icon_view`.
void ConfigureIconView(views::ImageView* icon_view, bool is_visible) {
  DCHECK(icon_view);
  icon_view->SetImageSize(capture_mode::kSettingsIconSize);
  icon_view->SetPreferredSize(capture_mode::kSettingsIconSize);
  icon_view->SetVisible(is_visible);
}

}  // namespace

// -----------------------------------------------------------------------------
// CaptureModeMenuHeader:

// The header of the menu group, which has an icon and a text label. Not user
// interactable.
class CaptureModeMenuHeader
    : public views::View,
      public CaptureModeSessionFocusCycler::HighlightableView {
  METADATA_HEADER(CaptureModeMenuHeader, views::View)

 public:
  CaptureModeMenuHeader(const gfx::VectorIcon& icon,
                        std::u16string header_laber,
                        bool managed_by_policy)
      : icon_view_(AddChildView(std::make_unique<views::ImageView>())),
        label_view_(AddChildView(
            std::make_unique<views::Label>(std::move(header_laber)))),
        managed_icon_view_(
            managed_by_policy
                ? AddChildView(std::make_unique<views::ImageView>())
                : nullptr) {
    icon_view_->SetImageSize(capture_mode::kSettingsIconSize);
    icon_view_->SetPreferredSize(capture_mode::kSettingsIconSize);
    icon_view_->SetImage(
        ui::ImageModel::FromVectorIcon(icon, kColorAshButtonIconColor));

    if (managed_icon_view_) {
      managed_icon_view_->SetImageSize(capture_mode::kSettingsIconSize);
      managed_icon_view_->SetPreferredSize(capture_mode::kSettingsIconSize);
      managed_icon_view_->SetImage(ui::ImageModel::FromVectorIcon(
          kCaptureModeManagedIcon, kColorAshIconColorSecondary));
      managed_icon_view_->SetTooltipText(
          l10n_util::GetStringUTF16(IDS_ASH_SCREEN_CAPTURE_MANAGED_BY_POLICY));
    }

    SetBorder(views::CreateEmptyBorder(capture_mode::kSettingsMenuBorderSize));
    capture_mode_util::ConfigLabelView(label_view_);
    auto* box_layout = capture_mode_util::CreateAndInitBoxLayoutForView(this);
    box_layout->SetFlexForView(label_view_, 1);

    GetViewAccessibility().SetRole(ax::mojom::Role::kHeader);
  }

  CaptureModeMenuHeader(const CaptureModeMenuHeader&) = delete;
  CaptureModeMenuHeader& operator=(const CaptureModeMenuHeader&) = delete;
  ~CaptureModeMenuHeader() override = default;

  bool is_managed_by_policy() const { return !!managed_icon_view_; }

  const std::u16string& GetHeaderLabel() const {
    return label_view_->GetText();
  }

  // views::View:
  void GetAccessibleNodeData(ui::AXNodeData* node_data) override {
    View::GetAccessibleNodeData(node_data);
    node_data->SetName(GetHeaderLabel());
  }

  // CaptureModeSessionFocusCycler::HighlightableView:
  views::View* GetView() override { return this; }

 private:
  raw_ptr<views::ImageView> icon_view_;
  raw_ptr<views::Label> label_view_;
  // `nullptr` if the menu group is not for a setting that is managed by a
  // policy.
  raw_ptr<views::ImageView> managed_icon_view_;
};

BEGIN_METADATA(CaptureModeMenuHeader)
END_METADATA

// -----------------------------------------------------------------------------
// CaptureModeMenuItem:

// A button which has a text label. Its behavior on click can be customized.
// For selecting folder, a folder window will be opened on click.
class CaptureModeMenuItem
    : public views::Button,
      public CaptureModeSessionFocusCycler::HighlightableView {
  METADATA_HEADER(CaptureModeMenuItem, views::Button)

 public:
  // If `indented` is true, the content of this menu item will have some extra
  // padding from the left so that it appears indented. This is useful when this
  // item is added to a group that has a header, and it's desired to make it
  // appear to be pushed inside under the header.
  CaptureModeMenuItem(views::Button::PressedCallback callback,
                      std::u16string item_label,
                      bool indented,
                      bool enabled)
      : views::Button(std::move(callback)),
        label_view_(AddChildView(
            std::make_unique<views::Label>(std::move(item_label)))) {
    SetBorder(views::CreateEmptyBorder(indented ? kIndentedMenuItemPadding
                                                : kMenuItemPadding));
    capture_mode_util::ConfigLabelView(label_view_);
    capture_mode_util::CreateAndInitBoxLayoutForView(this);
    SetInkDropForButton(this);
    GetViewAccessibility().SetIsLeaf(true);
    GetViewAccessibility().SetName(label_view_->GetText());
    SetEnabled(enabled);
  }

  CaptureModeMenuItem(const CaptureModeMenuItem&) = delete;
  CaptureModeMenuItem& operator=(const CaptureModeMenuItem&) = delete;
  ~CaptureModeMenuItem() override = default;

  // CaptureModeSessionFocusCycler::HighlightableView:
  views::View* GetView() override { return this; }

 private:
  raw_ptr<views::Label> label_view_;
};

BEGIN_METADATA(CaptureModeMenuItem)
END_METADATA

// -----------------------------------------------------------------------------
// CaptureModeOption:

// A button which represents an option of the menu group. It has a text label
// and a checked icon. The checked icon will be shown on button click and any
// other option's checked icon will be set to invisible in the meanwhile. One
// and only one checked icon is visible in the menu group.
class CaptureModeOption
    : public views::Button,
      public CaptureModeSessionFocusCycler::HighlightableView {
  METADATA_HEADER(CaptureModeOption, views::Button)

 public:
  // If `indented` is true, the content of this option will have some extra
  // padding from the left so that it appears indented. This is useful when this
  // option is added to a group that has a header, and it's desired to make it
  // appear to be pushed inside under the header.
  CaptureModeOption(views::Button::PressedCallback callback,
                    const gfx::VectorIcon* option_icon,
                    std::u16string option_label,
                    int option_id,
                    bool checked,
                    bool enabled,
                    bool indented)
      : views::Button(std::move(callback)),
        option_icon_(option_icon),
        option_icon_view_(
            option_icon_ ? AddChildView(std::make_unique<views::ImageView>())
                         : nullptr),
        label_view_(AddChildView(
            std::make_unique<views::Label>(std::move(option_label)))),
        checked_icon_view_(AddChildView(std::make_unique<views::ImageView>())),
        id_(option_id) {
    if (option_icon_view_)
      ConfigureIconView(option_icon_view_, /*is_visible=*/true);
    ConfigureIconView(checked_icon_view_, /*is_visible=*/checked);
    SetBorder(views::CreateEmptyBorder(indented ? kIndentedOptionPadding
                                                : kOptionPadding));
    capture_mode_util::ConfigLabelView(label_view_);
    auto* box_layout = capture_mode_util::CreateAndInitBoxLayoutForView(this);
    box_layout->SetFlexForView(label_view_, 1);
    SetInkDropForButton(this);
    GetViewAccessibility().SetIsLeaf(true);
    GetViewAccessibility().SetName(GetOptionLabel());
    GetViewAccessibility().SetRole(ax::mojom::Role::kRadioButton);

    SetEnabled(enabled);
  }

  CaptureModeOption(const CaptureModeOption&) = delete;
  CaptureModeOption& operator=(const CaptureModeOption&) = delete;
  ~CaptureModeOption() override = default;

  int id() const { return id_; }

  const std::u16string& GetOptionLabel() const {
    return label_view_->GetText();
  }

  // If `icon` is `nullptr`, removes the `option_icon_view_` (if any).
  // Otherwise, a new image view will be created for the `option_icon_view_` (if
  // needed), and the given `icon` will be set.
  void SetOptionIcon(const gfx::VectorIcon* icon) {
    option_icon_ = icon;

    if (!option_icon_) {
      if (option_icon_view_) {
        RemoveChildViewT(option_icon_view_.get());
        option_icon_view_ = nullptr;
      }
      return;
    }

    if (!option_icon_view_) {
      option_icon_view_ =
          AddChildViewAt(std::make_unique<views::ImageView>(), 0);
      ConfigureIconView(option_icon_view_, /*is_visible=*/true);
    }

    MaybeUpdateOptionIconState();
  }

  void SetOptionLabel(std::u16string option_label) {
    GetViewAccessibility().SetName(option_label);
    label_view_->SetText(std::move(option_label));
  }

  void SetOptionChecked(bool checked) {
    checked_icon_view_->SetVisible(checked);
    GetViewAccessibility().SetCheckedState(
        checked ? ax::mojom::CheckedState::kTrue
                : ax::mojom::CheckedState::kFalse);
  }

  bool IsOptionChecked() { return checked_icon_view_->GetVisible(); }

  // views::Button:
  void StateChanged(ButtonState old_state) override {
    // Don't trigger `UpdateState` when the option is not added to the views
    // hierarchy yet, since we need to get the color from the widget's color
    // provider. When the option is added to the view hierarchy,
    // `OnThemeChanged` will be triggered and then `UpdateState` will be called.
    if (GetWidget())
      UpdateState();
  }

  void OnThemeChanged() override {
    views::Button::OnThemeChanged();
    UpdateState();
  }

  // CaptureModeSessionFocusCycler::HighlightableView:
  views::View* GetView() override { return this; }

 private:
  // Dims out the label and the checked icon if this view is disabled.
  void UpdateState() {
    MaybeUpdateOptionIconState();

    const bool is_disabled = GetState() == STATE_DISABLED;
    const auto* color_provider = GetColorProvider();
    const auto label_enabled_color =
        color_provider->GetColor(kColorAshTextColorPrimary);
    label_view_->SetEnabledColor(
        is_disabled ? ColorUtil::GetDisabledColor(label_enabled_color)
                    : label_enabled_color);

    const auto checked_icon_enabled_color =
        color_provider->GetColor(kColorAshButtonLabelColorBlue);
    checked_icon_view_->SetImage(gfx::CreateVectorIcon(
        kHollowCheckCircleIcon,
        is_disabled ? ColorUtil::GetDisabledColor(checked_icon_enabled_color)
                    : checked_icon_enabled_color));
  }

  void MaybeUpdateOptionIconState() {
    if (!option_icon_view_) {
      DCHECK(!option_icon_);
      return;
    }

    DCHECK(option_icon_);
    const bool is_disabled = GetState() == STATE_DISABLED;
    option_icon_view_->SetImage(ui::ImageModel::FromVectorIcon(
        *option_icon_, is_disabled ? kColorAshButtonIconDisabledColor
                                   : kColorAshButtonIconColor));
  }

  // An optional icon for the option. Non-null if present.
  raw_ptr<const gfx::VectorIcon> option_icon_ = nullptr;
  raw_ptr<views::ImageView> option_icon_view_ = nullptr;

  raw_ptr<views::Label> label_view_;
  raw_ptr<views::ImageView> checked_icon_view_;
  const int id_;
};

BEGIN_METADATA(CaptureModeOption)
END_METADATA

// -----------------------------------------------------------------------------
// CaptureModeMenuGroup:

CaptureModeMenuGroup::CaptureModeMenuGroup(
    Delegate* delegate,
    const gfx::Insets& inside_border_insets)
    : CaptureModeMenuGroup(delegate,
                           /*menu_header=*/nullptr,
                           inside_border_insets) {}

CaptureModeMenuGroup::CaptureModeMenuGroup(Delegate* delegate,
                                           const gfx::VectorIcon& header_icon,
                                           std::u16string header_label,
                                           bool managed_by_policy)
    : CaptureModeMenuGroup(
          delegate,
          std::make_unique<CaptureModeMenuHeader>(header_icon,
                                                  std::move(header_label),
                                                  managed_by_policy),
          kMenuGroupPadding) {}

CaptureModeMenuGroup::~CaptureModeMenuGroup() = default;

bool CaptureModeMenuGroup::IsManagedByPolicy() const {
  return menu_header_ && menu_header_->is_managed_by_policy();
}

void CaptureModeMenuGroup::AddOption(const gfx::VectorIcon* option_icon,
                                     std::u16string option_label,
                                     int option_id) {
  options_.push_back(
      options_container_->AddChildView(std::make_unique<CaptureModeOption>(
          base::BindRepeating(&CaptureModeMenuGroup::HandleOptionClick,
                              base::Unretained(this), option_id),
          option_icon, std::move(option_label), option_id,
          /*checked=*/delegate_->IsOptionChecked(option_id),
          /*enabled=*/delegate_->IsOptionEnabled(option_id),
          /*indented=*/!!menu_header_)));
}

void CaptureModeMenuGroup::DeleteOptions() {
  for (CaptureModeOption* option : options_)
    options_container_->RemoveChildViewT(option);
  options_.clear();
}

void CaptureModeMenuGroup::AddOrUpdateExistingOption(
    const gfx::VectorIcon* option_icon,
    std::u16string option_label,
    int option_id) {
  auto* option = GetOptionById(option_id);

  if (option) {
    option->SetOptionIcon(option_icon);
    option->SetOptionLabel(std::move(option_label));
    return;
  }

  AddOption(option_icon, std::move(option_label), option_id);
}

void CaptureModeMenuGroup::RefreshOptionsSelections() {
  for (ash::CaptureModeOption* option : options_) {
    option->SetOptionChecked(delegate_->IsOptionChecked(option->id()));
    option->SetEnabled(delegate_->IsOptionEnabled(option->id()));
  }
}

void CaptureModeMenuGroup::RemoveOptionIfAny(int option_id) {
  auto* option = GetOptionById(option_id);
  if (!option)
    return;

  options_container_->RemoveChildViewT(option);
  std::erase(options_, option);
}

void CaptureModeMenuGroup::AddMenuItem(views::Button::PressedCallback callback,
                                       std::u16string item_label,
                                       bool enabled) {
  menu_items_.push_back(
      views::View::AddChildView(std::make_unique<CaptureModeMenuItem>(
          std::move(callback), std::move(item_label),
          /*indented=*/!!menu_header_, enabled)));
}

bool CaptureModeMenuGroup::IsOptionChecked(int option_id) const {
  auto* option = GetOptionById(option_id);
  return option && option->IsOptionChecked();
}

views::View* CaptureModeMenuGroup::SetOptionCheckedForTesting(
    int option_id,
    bool checked) const {
  auto* option = GetOptionById(option_id);
  option->SetOptionChecked(checked);
  return option;
}

bool CaptureModeMenuGroup::IsOptionEnabled(int option_id) const {
  auto* option = GetOptionById(option_id);
  return option && option->GetEnabled();
}

void CaptureModeMenuGroup::AppendHighlightableItems(
    std::vector<CaptureModeSessionFocusCycler::HighlightableView*>&
        highlightable_items) {
  // The camera menu group can be hidden if there are no cameras connected. In
  // this case no items in this group should be highlightable.
  if (!GetVisible())
    return;

  if (menu_header_)
    highlightable_items.push_back(menu_header_);
  for (ash::CaptureModeOption* option : options_) {
    if (option->GetEnabled())
      highlightable_items.push_back(option);
  }
  for (ash::CaptureModeMenuItem* menu_item : menu_items_) {
    highlightable_items.push_back(menu_item);
  }
}

views::View* CaptureModeMenuGroup::GetOptionForTesting(int option_id) {
  return GetOptionById(option_id);
}

views::View* CaptureModeMenuGroup::GetSelectFolderMenuItemForTesting() {
  DCHECK_EQ(1u, menu_items_.size());
  return menu_items_[0];
}

std::u16string CaptureModeMenuGroup::GetOptionLabelForTesting(
    int option_id) const {
  auto* option = GetOptionById(option_id);
  DCHECK(option);
  return option->GetOptionLabel();
}

CaptureModeMenuGroup::CaptureModeMenuGroup(
    Delegate* delegate,
    std::unique_ptr<CaptureModeMenuHeader> menu_header,
    const gfx::Insets& inside_border_insets)
    : delegate_(delegate),
      menu_header_(menu_header ? AddChildView(std::move(menu_header))
                               : nullptr),
      options_container_(AddChildView(std::make_unique<views::View>())) {
  DCHECK(delegate_);
  options_container_->SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical));
  SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical, inside_border_insets,
      kSpaceBetweenMenuItem));
}

CaptureModeOption* CaptureModeMenuGroup::GetOptionById(int option_id) const {
  auto iter = base::ranges::find(options_, option_id, &CaptureModeOption::id);
  return iter == options_.end() ? nullptr : *iter;
}

void CaptureModeMenuGroup::HandleOptionClick(int option_id) {
  DCHECK(GetOptionById(option_id));

  // The order here matters. We need to tell the delegate first about a change
  // in the selection, before we refresh the checked icons, since for that we
  // need to query the delegate.
  delegate_->OnOptionSelected(option_id);
  RefreshOptionsSelections();
}

views::View* CaptureModeMenuGroup::menu_header() const {
  return menu_header_;
}

BEGIN_METADATA(CaptureModeMenuGroup)
END_METADATA

}  // namespace ash