chromium/chrome/browser/ui/views/mahi/mahi_menu_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 "chrome/browser/ui/views/mahi/mahi_menu_view.h"

#include <algorithm>
#include <memory>
#include <string>

#include "base/check_is_test.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/metrics/histogram_functions.h"
#include "base/notreached.h"
#include "chrome/browser/chromeos/mahi/mahi_browser_util.h"
#include "chrome/browser/chromeos/mahi/mahi_web_contents_manager.h"
#include "chrome/browser/ui/chromeos/magic_boost/magic_boost_constants.h"
#include "chrome/browser/ui/views/editor_menu/utils/pre_target_handler.h"
#include "chrome/browser/ui/views/editor_menu/utils/pre_target_handler_view.h"
#include "chrome/browser/ui/views/editor_menu/utils/utils.h"
#include "chrome/browser/ui/views/mahi/mahi_menu_constants.h"
#include "chromeos/components/magic_boost/public/cpp/views/experiment_badge.h"
#include "chromeos/components/mahi/public/cpp/mahi_manager.h"
#include "chromeos/components/mahi/public/cpp/mahi_media_app_content_manager.h"
#include "chromeos/strings/grit/chromeos_strings.h"
#include "chromeos/ui/vector_icons/vector_icons.h"
#include "components/vector_icons/vector_icons.h"
#include "ui/accessibility/ax_enums.mojom-shared.h"
#include "ui/accessibility/ax_node_data.h"
#include "ui/base/ime/text_input_type.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/color/color_id.h"
#include "ui/display/screen.h"
#include "ui/events/event.h"
#include "ui/events/keycodes/keyboard_codes_posix.h"
#include "ui/events/types/event_type.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/geometry/size.h"
#include "ui/gfx/vector_icon_types.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/button.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/controls/focus_ring.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/label.h"
#include "ui/views/controls/menu/menu_controller.h"
#include "ui/views/controls/textfield/textfield.h"
#include "ui/views/controls/textfield/textfield_controller.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/layout/layout_types.h"
#include "ui/views/style/typography.h"
#include "ui/views/view.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/unique_widget_ptr.h"
#include "ui/views/widget/widget.h"

namespace chromeos::mahi {

namespace {

constexpr char kWidgetName[] = "MahiMenuViewWidget";

constexpr gfx::Insets kMenuPadding = gfx::Insets::TLBR(12, 16, 12, 14);
constexpr int kButtonHeight = 16;
constexpr int kButtonCornerRadius = 8;
constexpr gfx::Insets kButtonPadding = gfx::Insets::VH(6, 8);
constexpr gfx::Insets kHeaderRowPadding = gfx::Insets::TLBR(0, 0, 12, 0);
constexpr int kHeaderRowSpacing = 8;
constexpr int kButtonsRowSpacing = 12;
constexpr int kButtonTextfieldSpacing = 16;
constexpr int kButtonImageLabelSpacing = 4;
constexpr int kButtonBorderThickness = 1;
constexpr int kTextfieldContainerSpacing = 8;
constexpr int kInputContainerCornerRadius = 8;
constexpr gfx::Insets kTextfieldButtonPadding = gfx::Insets::VH(0, 8);

void StyleMenuButton(views::LabelButton* button, const gfx::VectorIcon& icon) {
  button->SetLabelStyle(views::style::STYLE_BODY_4_EMPHASIS);
  button->SetImageModel(views::Button::ButtonState::STATE_NORMAL,
                        ui::ImageModel::FromVectorIcon(
                            icon, ui::kColorSysOnSurface, kButtonHeight));
  button->SetTextColorId(views::LabelButton::ButtonState::STATE_NORMAL,
                         ui::kColorSysOnSurface);
  button->SetImageLabelSpacing(kButtonImageLabelSpacing);
  button->SetBorder(views::CreatePaddedBorder(
      views::CreateThemedRoundedRectBorder(kButtonBorderThickness,
                                           kButtonCornerRadius,
                                           ui::kColorSysTonalOutline),
      kButtonPadding));
}

// Custom widget to ensure the MahiMenuView follows the same theme as the
// browser context menu.
class MahiMenuWidget : public views::Widget {
 public:
  explicit MahiMenuWidget(views::Widget::InitParams init_params)
      : views::Widget(std::move(init_params)) {}
  MahiMenuWidget(const Widget&) = delete;
  MahiMenuWidget& operator=(const Widget&) = delete;
  ~MahiMenuWidget() override = default;

 protected:
  const ui::ColorProvider* GetColorProvider() const override {
    // Get the color provider for the active menu controller's owner if possible
    // to match the color theme for the browser.
    auto* active_menu_controller = views::MenuController::GetActiveInstance();

    // The menu might already be closed.
    if (active_menu_controller && active_menu_controller->owner()) {
      return active_menu_controller->owner()->GetColorProvider();
    }

    return views::Widget::GetColorProvider();
  }
};

}  // namespace

// Controller for the `textfield_` owned by `MahiMenuView`. Enables the
// `submit_question_button` only when the `textfield_` contains some input.
// Also, submits a question if the user presses the enter key while focused on
// the textfield.
class MahiMenuView::MenuTextfieldController
    : public views::TextfieldController {
 public:
  explicit MenuTextfieldController(base::WeakPtr<MahiMenuView> menu_view)
      : menu_view_(menu_view) {}
  MenuTextfieldController(const MenuTextfieldController&) = delete;
  MenuTextfieldController& operator=(const MenuTextfieldController&) = delete;
  ~MenuTextfieldController() override = default;

 private:
  // views::TextfieldController:
  bool HandleKeyEvent(views::Textfield* sender,
                      const ui::KeyEvent& event) override {
    // Do not try to send a reply if no text has been input.
    if (!menu_view_ || sender->GetText().empty()) {
      return false;
    }

    if (event.type() == ui::EventType::kKeyPressed &&
        event.key_code() == ui::VKEY_RETURN) {
      menu_view_->OnQuestionSubmitted();
      return true;
    }

    return false;
  }
  void OnAfterUserAction(views::Textfield* sender) override {
    if (!menu_view_) {
      return;
    }

    bool enabled = !sender->GetText().empty();
    menu_view_->GetViewByID(ViewID::kSubmitQuestionButton)->SetEnabled(enabled);
  }

  base::WeakPtr<MahiMenuView> menu_view_;
};

MahiMenuView::MahiMenuView(Surface surface)
    : chromeos::editor_menu::PreTargetHandlerView(
          chromeos::editor_menu::CardType::kMahiDefaultMenu),
      surface_(surface) {
  SetBackground(views::CreateThemedRoundedRectBackground(
      ui::kColorPrimaryBackground,
      views::LayoutProvider::Get()->GetCornerRadiusMetric(
          views::ShapeContextTokens::kMenuRadius)));

  auto* layout = SetLayoutManager(std::make_unique<views::FlexLayout>());
  layout->SetOrientation(views::LayoutOrientation::kVertical);
  layout->SetInteriorMargin(kMenuPadding);

  auto header_row = std::make_unique<views::FlexLayoutView>();
  header_row->SetOrientation(views::LayoutOrientation::kHorizontal);
  header_row->SetInteriorMargin(kHeaderRowPadding);

  auto header_left_container = std::make_unique<views::FlexLayoutView>();
  header_left_container->SetOrientation(views::LayoutOrientation::kHorizontal);
  header_left_container->SetMainAxisAlignment(views::LayoutAlignment::kStart);
  header_left_container->SetCrossAxisAlignment(views::LayoutAlignment::kCenter);
  header_left_container->SetDefault(
      views::kMarginsKey, gfx::Insets::TLBR(0, 0, 0, kHeaderRowSpacing));
  header_left_container->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(
          views::FlexSpecification(views::MinimumFlexSizeRule::kPreferred,
                                   views::MaximumFlexSizeRule::kUnbounded)));

  auto* header_label =
      header_left_container->AddChildView(std::make_unique<views::Label>(
          l10n_util::GetStringUTF16(IDS_ASH_MAHI_MENU_TITLE),
          views::style::CONTEXT_DIALOG_TITLE, views::style::STYLE_HEADLINE_5));
  header_label->SetEnabledColorId(ui::kColorSysOnSurface);
  header_label->SetHorizontalAlignment(gfx::HorizontalAlignment::ALIGN_LEFT);
  header_label->GetViewAccessibility().SetRole(ax::mojom::Role::kHeading);

  header_left_container->AddChildView(
      std::make_unique<chromeos::ExperimentBadge>());

  header_row->AddChildView(std::move(header_left_container));

  settings_button_ =
      header_row->AddChildView(views::ImageButton::CreateIconButton(
          base::BindRepeating(&MahiMenuView::OnButtonPressed,
                              weak_ptr_factory_.GetWeakPtr(),
                              ::chromeos::mahi::ButtonType::kSettings),
          vector_icons::kSettingsOutlineIcon,
          l10n_util::GetStringUTF16(IDS_EDITOR_MENU_SETTINGS_TOOLTIP)));
  settings_button_->SetID(ViewID::kSettingsButton);

  AddChildView(std::move(header_row));

  // Create row containing the `summary_button_` and `outline_button_`.
  AddChildView(
      views::Builder<views::FlexLayoutView>()
          .SetOrientation(views::LayoutOrientation::kHorizontal)
          .SetProperty(views::kCrossAxisAlignmentKey,
                       views::LayoutAlignment::kStart)
          .AddChildren(
              views::Builder<views::LabelButton>()
                  .SetID(ViewID::kSummaryButton)
                  .CopyAddressTo(&summary_button_)
                  .SetCallback(base::BindRepeating(
                      &MahiMenuView::OnButtonPressed,
                      weak_ptr_factory_.GetWeakPtr(),
                      ::chromeos::mahi::ButtonType::kSummary))
                  .SetText(l10n_util::GetStringUTF16(
                      IDS_MAHI_SUMMARIZE_BUTTON_LABEL_TEXT))
                  .SetProperty(views::kMarginsKey,
                               gfx::Insets::TLBR(0, 0, 0, kButtonsRowSpacing)),
              views::Builder<views::LabelButton>()
                  .SetID(ViewID::kOutlineButton)
                  .CopyAddressTo(&outline_button_)
                  .SetCallback(base::BindRepeating(
                      &MahiMenuView::OnButtonPressed,
                      weak_ptr_factory_.GetWeakPtr(),
                      ::chromeos::mahi::ButtonType::kOutline))
                  .SetText(l10n_util::GetStringUTF16(
                      IDS_MAHI_OUTLINE_BUTTON_LABEL_TEXT))
                  // TODO(b/330643995): Unhide the outline button once outlines
                  // are ready to be shown by default.
                  .SetVisible(false))
          .Build());

  StyleMenuButton(summary_button_, chromeos::kMahiSummarizeIcon);
  StyleMenuButton(outline_button_, chromeos::kMahiOutlinesIcon);

  textfield_controller_ =
      std::make_unique<MenuTextfieldController>(weak_ptr_factory_.GetWeakPtr());
  AddChildView(CreateInputContainer());
}

MahiMenuView::~MahiMenuView() {
  // `textfield_` keeps a raw pointer to `textfield_controller_` - reset that
  // before destroying the controller.
  textfield_->SetController(nullptr);
}

// static
views::UniqueWidgetPtr MahiMenuView::CreateWidget(
    const gfx::Rect& anchor_view_bounds,
    Surface surface) {
  views::Widget::InitParams params(
      views::Widget::InitParams::NATIVE_WIDGET_OWNS_WIDGET,
      views::Widget::InitParams::TYPE_POPUP);
  params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent;
  params.activatable = views::Widget::InitParams::Activatable::kYes;
  params.shadow_elevation = 2;
  params.shadow_type = views::Widget::InitParams::ShadowType::kDrop;
  params.name = GetWidgetName();
#if BUILDFLAG(IS_CHROMEOS_ASH)
  params.init_properties_container.SetProperty(kIsMahiMenuKey, true);
#endif

  views::UniqueWidgetPtr widget =
      std::make_unique<MahiMenuWidget>(std::move(params));
  MahiMenuView* mahi_menu_view =
      widget->SetContentsView(std::make_unique<MahiMenuView>(surface));
  mahi_menu_view->UpdateBounds(anchor_view_bounds);

  return widget;
}

// static
const char* MahiMenuView::GetWidgetName() {
  return kWidgetName;
}

void MahiMenuView::RequestFocus() {
  views::View::RequestFocus();

  // TODO(b/319735347): Add browsertest for this behavior.
  settings_button_->RequestFocus();
}

void MahiMenuView::GetAccessibleNodeData(ui::AXNodeData* node_data) {
  node_data->role = ax::mojom::Role::kDialog;
  node_data->SetName(l10n_util::GetStringUTF16(IDS_ASH_MAHI_MENU_TITLE));
}

void MahiMenuView::UpdateBounds(const gfx::Rect& anchor_view_bounds) {
  // TODO(b/318733414): Move `editor_menu::GetEditorMenuBounds` to a common
  // place for use
  GetWidget()->SetBounds(editor_menu::GetEditorMenuBounds(
      anchor_view_bounds, this, editor_menu::CardType::kMahiDefaultMenu));
}

void MahiMenuView::OnButtonPressed(::chromeos::mahi::ButtonType button_type) {
  auto display = display::Screen::GetScreen()->GetDisplayNearestWindow(
      GetWidget()->GetNativeWindow());
  if (surface_ == Surface::kBrowser) {
    ::mahi::MahiWebContentsManager::Get()->OnContextMenuClicked(
        display.id(), button_type,
        /*question=*/std::u16string(), GetBoundsInScreen());
  } else if (surface_ == Surface::kMediaApp) {
#if BUILDFLAG(IS_CHROMEOS_ASH)
    // Only ash chrome has `surface_` = kMediaApp
    CHECK(chromeos::MahiMediaAppContentManager::Get());
    chromeos::MahiMediaAppContentManager::Get()->OnMahiContextMenuClicked(
        display.id(), button_type,
        /*question=*/std::u16string(), GetBoundsInScreen());
#endif
  }

  MahiMenuButton histogram_button_type;
  switch (button_type) {
    case ::chromeos::mahi::ButtonType::kSummary:
      histogram_button_type = MahiMenuButton::kSummaryButton;
      break;
    case ::chromeos::mahi::ButtonType::kOutline:
      // TODO(b/330643995): Remove CHECK_IS_TEST when outlines are
      // ready.
      CHECK_IS_TEST();
      histogram_button_type = MahiMenuButton::kOutlineButton;
      break;
    case ::chromeos::mahi::ButtonType::kSettings:
      histogram_button_type = MahiMenuButton::kSettingsButton;
      break;
    default:
      // This function only handles clicks of type 'kSummary',
      // 'kOutline' and `kSettings`. Other click types are not passed
      // here.
      NOTREACHED();
  }
  base::UmaHistogramEnumeration(kMahiContextMenuButtonClickHistogram,
                                histogram_button_type);
}

void MahiMenuView::OnQuestionSubmitted() {
  auto display = display::Screen::GetScreen()->GetDisplayNearestWindow(
      GetWidget()->GetNativeWindow());
  if (surface_ == Surface::kBrowser) {
    ::mahi::MahiWebContentsManager::Get()->OnContextMenuClicked(
        display.id(), /*button_type=*/::chromeos::mahi::ButtonType::kQA,
        textfield_->GetText(), GetBoundsInScreen());
  } else if (surface_ == Surface::kMediaApp) {
#if BUILDFLAG(IS_CHROMEOS_ASH)
    // Only ash chrome has `surface_` = kMediaApp
    CHECK(chromeos::MahiMediaAppContentManager::Get());
    chromeos::MahiMediaAppContentManager::Get()->OnMahiContextMenuClicked(
        display.id(), ::chromeos::mahi::ButtonType::kQA, textfield_->GetText(),
        GetBoundsInScreen());
#endif
  }

  base::UmaHistogramEnumeration(kMahiContextMenuButtonClickHistogram,
                                MahiMenuButton::kSubmitQuestionButton);
}

std::unique_ptr<views::FlexLayoutView> MahiMenuView::CreateInputContainer() {
  auto input_container =
      views::Builder<views::FlexLayoutView>()
          .SetOrientation(views::LayoutOrientation::kHorizontal)
          .SetBackground(views::CreateThemedRoundedRectBackground(
              ui::kColorSysStateHoverOnSubtle, kInputContainerCornerRadius))
          .SetCrossAxisAlignment(views::LayoutAlignment::kCenter)
          .SetProperty(views::kMarginsKey,
                       gfx::Insets::TLBR(kButtonTextfieldSpacing, 0, 0, 0))
          .AddChildren(
              views::Builder<views::Textfield>()
                  .SetID(ViewID::kTextfield)
                  .CopyAddressTo(&textfield_)
                  .SetController(textfield_controller_.get())
                  .SetTextInputType(ui::TEXT_INPUT_TYPE_TEXT)
                  .SetPlaceholderText(
                      l10n_util::GetStringUTF16(IDS_MAHI_MENU_INPUT_TEXTHOLDER))
                  .SetAccessibleName(
                      l10n_util::GetStringUTF16(IDS_MAHI_MENU_INPUT_TEXTHOLDER))
                  .SetProperty(
                      views::kFlexBehaviorKey,
                      views::FlexSpecification(views::FlexSpecification(
                          views::LayoutOrientation::kHorizontal,
                          views::MinimumFlexSizeRule::kPreferred,
                          views::MaximumFlexSizeRule::kUnbounded)))
                  .SetProperty(views::kMarginsKey,
                               gfx::Insets::TLBR(0, kTextfieldContainerSpacing,
                                                 0, kTextfieldContainerSpacing))
                  .SetBackgroundEnabled(false)
                  .SetBorder(nullptr),
              views::Builder<views::ImageButton>()
                  .SetID(ViewID::kSubmitQuestionButton)
                  .CopyAddressTo(&submit_question_button_)
                  .SetCallback(
                      base::BindRepeating(&MahiMenuView::OnQuestionSubmitted,
                                          weak_ptr_factory_.GetWeakPtr()))
                  .SetImageModel(
                      views::Button::STATE_NORMAL,
                      ui::ImageModel::FromVectorIcon(vector_icons::kSendIcon))
                  .SetImageModel(
                      views::Button::STATE_DISABLED,
                      ui::ImageModel::FromVectorIcon(
                          vector_icons::kSendIcon, ui::kColorSysStateDisabled))
                  .SetAccessibleName(l10n_util::GetStringUTF16(
                      IDS_MAHI_MENU_INPUT_SEND_BUTTON_ACCESSIBLE_NAME))
                  .SetProperty(views::kMarginsKey, kTextfieldButtonPadding)
                  .SetEnabled(false))
          .Build();

  // Focus ring insets need to be negative because we want the focus rings to
  // exceed the textfield bounds horizontally to cover the entire `container`.
  int focus_ring_left_inset = -1 * (kTextfieldContainerSpacing);
  int focus_ring_right_inset =
      -1 * (kTextfieldContainerSpacing + kTextfieldButtonPadding.width() +
            submit_question_button_->GetPreferredSize().width());

  views::FocusRing::Install(textfield_);
  views::FocusRing::Get(textfield_)->SetColorId(ui::kColorSysStateFocusRing);
  views::InstallRoundRectHighlightPathGenerator(
      textfield_,
      gfx::Insets::TLBR(0, focus_ring_left_inset, 0, focus_ring_right_inset),
      kInputContainerCornerRadius);

  return input_container;
}

BEGIN_METADATA(MahiMenuView)
END_METADATA

}  // namespace chromeos::mahi