chromium/ash/system/video_conference/bubble/settings_button.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/system/video_conference/bubble/settings_button.h"

#include "ash/public/cpp/system_tray_client.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/switch.h"
#include "ash/system/camera/camera_effects_controller.h"
#include "ash/system/model/system_tray_model.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/simple_menu_model.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/context_menu_controller.h"
#include "ui/views/controls/button/toggle_button.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/controls/menu/menu_item_view.h"
#include "ui/views/controls/menu/menu_model_adapter.h"
#include "ui/views/controls/menu/menu_runner.h"
#include "ui/views/controls/menu/submenu_view.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/style/typography_provider.h"

namespace ash::video_conference {

namespace {

// Rounded corner constants.
static constexpr int kRoundedCornerRadius = 16;
static constexpr int kNonRoundedCornerRadius = 4;
static constexpr int kIconSize = 20;
static constexpr int kIconSpacing = 16;
static constexpr int kMenuItemTopBottomPadding = 4;

enum CommandId {
  kAudioSettings = 1,
  kPrivacySettings = 2,
  kPortraitRelighting = 3,
  kFaceRetouch = 4,
};

constexpr gfx::Size kIconSizeGfx{kIconSize, kIconSize};

constexpr gfx::RoundedCornersF kTopRightNonRoundedCorners(
    kRoundedCornerRadius,
    kNonRoundedCornerRadius,
    kRoundedCornerRadius,
    kRoundedCornerRadius);

// A MenuItemView with a toggle. This is used for the Studio Look preference
// menu items.
class SwitchMenuItemView : public views::MenuItemView {
  METADATA_HEADER(SwitchMenuItemView, views::MenuItemView)

 public:
  SwitchMenuItemView(MenuItemView* parent,
                     int command_id,
                     const std::u16string& title)
      : views::MenuItemView(parent,
                            command_id,
                            views::MenuItemView::Type::kNormal) {
    // Creates a non-clickable non-focusable switch. The events and focus
    // behavior are handled by its parent.
    switch_ = AddChildView(std::make_unique<Switch>());
    switch_->SetIsOn(GetDelegate()->IsItemChecked(GetCommand()));
    switch_->SetCanProcessEventsWithinSubtree(false);
    switch_->SetFocusBehavior(views::View::FocusBehavior::NEVER);
    auto& view_accessibility = switch_->GetViewAccessibility();
    view_accessibility.SetIsLeaf(true);
    view_accessibility.SetIsIgnored(true);

    SetTitle(title);
    UpdateAccessibleName();

    // Adding a custom child view breaks highlighting. This is a workaround to
    // make highlighting work properly.
    SetHighlightWhenSelectedWithChildViews(true);
  }

  void UpdateAccessibleCheckedState() override {
    if (switch_) {
      switch_->AnimateIsOn(!switch_->GetIsOn());
      UpdateAccessibleName();
    }
  }

 private:
  void UpdateAccessibleName() {
    GetViewAccessibility().SetName(l10n_util::GetStringFUTF16(
        IDS_ASH_VIDEO_CONFERENCE_PREFERENCE_STATE_ACCESSIBLE_NAME,
        l10n_util::GetStringUTF16(
            IDS_ASH_VIDEO_CONFERENCE_SETTINGS_STUDIO_LOOK_PREFERENCE),
        title(),
        l10n_util::GetStringUTF16(
            switch_->GetIsOn() ? VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_ON
                               : VIDEO_CONFERENCE_TOGGLE_BUTTON_STATE_OFF)));
  }

  raw_ptr<ash::Switch> switch_ = nullptr;
};

BEGIN_METADATA(SwitchMenuItemView)
END_METADATA

class SettingsMenuModelAdapter : public views::MenuModelAdapter {
 public:
  explicit SettingsMenuModelAdapter(
      ui::MenuModel* menu_model,
      base::RepeatingClosure on_menu_closed_callback = base::NullCallback())
      : views::MenuModelAdapter(menu_model, on_menu_closed_callback) {}

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

 protected:
  views::MenuItemView* AppendMenuItem(views::MenuItemView* menu,
                                      ui::MenuModel* model,
                                      size_t index) override {
    int command_id = model->GetCommandIdAt(index);
    if (model->GetTypeAt(index) == ui::MenuModel::ItemType::TYPE_TITLE) {
      // Appends MenuItemView for Studio Look preference title.
      views::MenuItemView* container = menu->AppendMenuItem(command_id);
      container->AddChildView(
          views::Builder<views::BoxLayoutView>()
              .SetOrientation(views::BoxLayout::Orientation::kHorizontal)
              .SetInsideBorderInsets(gfx::Insets()
                                         .set_top(kMenuItemTopBottomPadding)
                                         .set_bottom(kMenuItemTopBottomPadding)
                                         .set_left(kIconSpacing))
              .SetBetweenChildSpacing(kIconSpacing)
              .AddChild(views::Builder<views::ImageView>()
                            .SetImage(ui::ImageModel::FromVectorIcon(
                                kVideoConferenceStudioLookIcon,
                                cros_tokens::kCrosSysOnSurface))
                            .SetImageSize(kIconSizeGfx))
              .AddChild(
                  views::Builder<views::Label>()
                      .SetText(l10n_util::GetStringUTF16(
                          IDS_ASH_VIDEO_CONFERENCE_SETTINGS_STUDIO_LOOK_PREFERENCE))
                      .SetFontList(views::TypographyProvider::Get().GetFont(
                          views::style::CONTEXT_TOUCH_MENU,
                          views::style::STYLE_PRIMARY)))
              .Build());
      container->GetViewAccessibility().SetIsIgnored(true);
      return container;
    }

    if (model->GetTypeAt(index) == ui::MenuModel::ItemType::TYPE_CHECK) {
      // Appends SwitchMenuItemView, which is a MenuItemView with a toggle.
      views::MenuItemView* container =
          menu->GetSubmenu()->AddChildView(std::make_unique<SwitchMenuItemView>(
              menu, command_id, model->GetLabelAt(index)));
      if (command_id == CommandId::kPortraitRelighting) {
        portrait_relighting_menu_item_view_ = container;
      } else if (command_id == CommandId::kFaceRetouch) {
        face_retouch_menu_item_view_ = container;
      }
      return container;
    }

    return AppendMenuItemFromModel(model, index, menu, command_id);
  }

  void ExecuteCommand(int command_id) override {
    ExecuteCommand(command_id, /*event_flags=*/0);
  }

  void ExecuteCommand(int command_id, int event_flags) override {
    views::MenuModelAdapter::ExecuteCommand(command_id, event_flags);
    switch (command_id) {
      case CommandId::kPortraitRelighting:
        if (portrait_relighting_menu_item_view_) {
          portrait_relighting_menu_item_view_->UpdateAccessibleCheckedState();
        }
        break;
      case CommandId::kFaceRetouch:
        if (face_retouch_menu_item_view_) {
          face_retouch_menu_item_view_->UpdateAccessibleCheckedState();
        }
        break;
    }
  }

  bool ShouldExecuteCommandWithoutClosingMenu(int command_id,
                                              const ui::Event& event) override {
    // The menu should not be closed when executing SwitchMenuItemView's
    // command.
    return command_id == CommandId::kPortraitRelighting ||
           command_id == CommandId::kFaceRetouch;
  }

  void OnMenuClosed(views::MenuItemView* menu) override {
    // Prevents dangling pointers.
    portrait_relighting_menu_item_view_ = nullptr;
    face_retouch_menu_item_view_ = nullptr;
    views::MenuModelAdapter::OnMenuClosed(menu);
  }

 private:
  raw_ptr<views::MenuItemView> portrait_relighting_menu_item_view_ = nullptr;
  raw_ptr<views::MenuItemView> face_retouch_menu_item_view_ = nullptr;
};

}  // namespace

class SettingsButton::MenuController : public ui::SimpleMenuModel::Delegate,
                                       public views::ContextMenuController {
 public:
  MenuController() = default;
  MenuController(const MenuController&) = delete;
  MenuController& operator=(const MenuController&) = delete;
  ~MenuController() override = default;

  // ui::SimpleMenuModel::Delegate:
  bool IsCommandIdChecked(int command_id) const override {
    std::optional<int> state;
    if (command_id == CommandId::kPortraitRelighting) {
      state =
          effects_controller_->GetEffectState(VcEffectId::kPortraitRelighting);
    } else if (command_id == CommandId::kFaceRetouch) {
      state = effects_controller_->GetEffectState(VcEffectId::kFaceRetouch);
    } else {
      return false;
    }
    return *state != 0;
  }

  // ui::SimpleMenuModel::Delegate:
  void ExecuteCommand(int command_id, int event_flags) override {
    auto* client = Shell::Get()->system_tray_model()->client();
    switch (command_id) {
      case CommandId::kAudioSettings:
        client->ShowAudioSettings();
        break;
      case CommandId::kPrivacySettings:
        client->ShowPrivacyAndSecuritySettings();
        break;
      case CommandId::kPortraitRelighting:
        effects_controller_->OnEffectControlActivated(
            VcEffectId::kPortraitRelighting, /*state=*/std::nullopt);
        break;
      case CommandId::kFaceRetouch:
        effects_controller_->OnEffectControlActivated(VcEffectId::kFaceRetouch,
                                                      /*state=*/std::nullopt);
        break;
    }
  }

  // views::ContextMenuController:
  void ShowContextMenuForViewImpl(views::View* source,
                                  const gfx::Point& point,
                                  ui::MenuSourceType source_type) override {
    BuildMenuModel();
    menu_model_adapter_ = std::make_unique<SettingsMenuModelAdapter>(
        menu_model_.get(), base::BindRepeating(&MenuController::OnMenuClosed,
                                               base::Unretained(this)));
    std::unique_ptr<views::MenuItemView> menu =
        menu_model_adapter_->CreateMenu();
    int run_types = views::MenuRunner::USE_ASH_SYS_UI_LAYOUT |
                    views::MenuRunner::CONTEXT_MENU |
                    views::MenuRunner::FIXED_ANCHOR;
    menu_runner_ =
        std::make_unique<views::MenuRunner>(std::move(menu), run_types);

    menu_runner_->RunMenuAt(source->GetWidget(), /*button_controller=*/nullptr,
                            source->GetBoundsInScreen(),
                            views::MenuAnchorPosition::kBubbleBottomLeft,
                            source_type, /*native_view_for_gestures=*/nullptr,
                            kTopRightNonRoundedCorners);
  }

 private:
  // Builds and saves SimpleMenuModel to `context_menu_model_`.
  void BuildMenuModel() {
    menu_model_ = std::make_unique<ui::SimpleMenuModel>(this);

    menu_model_->AddItemWithIcon(
        CommandId::kAudioSettings,
        l10n_util::GetStringUTF16(
            IDS_ASH_VIDEO_CONFERENCE_SETTINGS_MENU_AUDIO_SETTINGS),
        ui::ImageModel::FromVectorIcon(kPrivacyIndicatorsMicrophoneIcon,
                                       cros_tokens::kCrosSysOnSurface,
                                       kIconSize));
    menu_model_->AddItemWithIcon(
        CommandId::kPrivacySettings,
        l10n_util::GetStringUTF16(
            IDS_ASH_VIDEO_CONFERENCE_SETTINGS_MENU_PRIVACY_SETTINGS),
        ui::ImageModel::FromVectorIcon(
            kSecurityIcon, cros_tokens::kCrosSysOnSurface, kIconSize));
    menu_model_->AddSeparator(ui::NORMAL_SEPARATOR);

    // Adds Studio Look preference title. Creates an empty MenuItemView and
    // fills its content with custom style in
    // SettingsMenuModelAdapter::AppendMenuItem.
    menu_model_->AddTitle(std::u16string());

    menu_model_->AddCheckItem(
        CommandId::kPortraitRelighting,
        l10n_util::GetStringUTF16(
            IDS_ASH_VIDEO_CONFERENCE_SETTINGS_MENU_PORTRAIT_RELIGHTING));
    menu_model_->AddCheckItem(
        CommandId::kFaceRetouch,
        l10n_util::GetStringUTF16(
            IDS_ASH_VIDEO_CONFERENCE_SETTINGS_MENU_FACE_RETOUCH));
  }

  void OnMenuClosed() {
    menu_runner_.reset();
    menu_model_.reset();
    menu_model_adapter_.reset();
  }

  raw_ptr<CameraEffectsController> effects_controller_ =
      Shell::Get()->camera_effects_controller();
  std::unique_ptr<ui::SimpleMenuModel> menu_model_;
  std::unique_ptr<views::MenuModelAdapter> menu_model_adapter_;
  std::unique_ptr<views::MenuRunner> menu_runner_;
};

SettingsButton::SettingsButton()
    : views::Button(base::BindRepeating(&SettingsButton::OnButtonActivated,
                                        base::Unretained(this))),
      context_menu_(std::make_unique<MenuController>()) {
  auto* layout = SetLayoutManager(std::make_unique<views::BoxLayout>());
  layout->SetOrientation(views::BoxLayout::Orientation::kHorizontal);

  AddChildView(views::Builder<views::ImageView>()
                   .SetImage(ui::ImageModel::FromVectorIcon(
                       kSystemMenuSettingsIcon, cros_tokens::kCrosSysOnSurface))
                   .SetImageSize(kIconSizeGfx)
                   .Build());
  AddChildView(views::Builder<views::ImageView>()
                   .SetImage(ui::ImageModel::FromVectorIcon(
                       kDropDownArrowIcon, cros_tokens::kCrosSysOnSurface))
                   .SetImageSize(kIconSizeGfx)
                   .Build());
  SetTooltipText(l10n_util::GetStringUTF16(
      IDS_ASH_VIDEO_CONFERENCE_SETTINGS_BUTTON_TOOLTIP));
  GetViewAccessibility().SetName(l10n_util::GetStringUTF16(
      IDS_ASH_VIDEO_CONFERENCE_SETTINGS_BUTTON_TOOLTIP));
}

SettingsButton::~SettingsButton() = default;

void SettingsButton::OnButtonActivated(const ui::Event& event) {
  ui::MenuSourceType source_type;

  if (event.IsMouseEvent()) {
    source_type = ui::MENU_SOURCE_MOUSE;
  } else if (event.IsTouchEvent()) {
    source_type = ui::MENU_SOURCE_TOUCH;
  } else if (event.IsKeyEvent()) {
    source_type = ui::MENU_SOURCE_KEYBOARD;
  } else {
    source_type = ui::MENU_SOURCE_STYLUS;
  }

  context_menu_->ShowContextMenuForView(
      /*source=*/this, GetBoundsInScreen().CenterPoint(), source_type);
}

BEGIN_METADATA(SettingsButton)
END_METADATA

}  // namespace ash::video_conference