chromium/ash/style/drop_down_checkbox.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/style/drop_down_checkbox.h"

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

#include "ash/public/cpp/shell_window_ids.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/style/checkbox.h"
#include "ash/style/checkbox_group.h"
#include "ash/style/style_util.h"
#include "ash/style/typography.h"
#include "ash/wm/work_area_insets.h"
#include "base/functional/bind.h"
#include "ui/aura/env.h"
#include "ui/aura/window.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/list_model_observer.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/layer.h"
#include "ui/events/event.h"
#include "ui/events/event_handler.h"
#include "ui/events/event_target.h"
#include "ui/events/types/event_type.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/controls/focus_ring.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/highlight_border.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/mouse_constants.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/widget/widget.h"
#include "ui/wm/core/coordinate_conversion.h"

namespace ash {

namespace {

// The color constants.
constexpr ui::ColorId kActiveTitleAndIconColorId =
    cros_tokens::kCrosSysSystemOnPrimaryContainer;
constexpr ui::ColorId kInactiveTitleAndIconColorId =
    cros_tokens::kCrosSysOnSurface;
constexpr ui::ColorId kMenuTextColorId = cros_tokens::kCrosSysOnSurface;
constexpr ui::ColorId kMenuBackgroundColorId =
    cros_tokens::kCrosSysSystemBaseElevated;
constexpr ui::ColorId kDropDownCheckboxActiveColorId =
    cros_tokens::kCrosSysSystemPrimaryContainer;

// The layout parameters.
constexpr int kDropDownCheckboxRoundedCorners = 12;
constexpr int kMenuRoundedCorners = 12;
constexpr gfx::Insets kDropDownCheckboxBorderInsets =
    gfx::Insets::TLBR(4, 10, 4, 4);
constexpr gfx::Insets kMenuBorderInsets = gfx::Insets::TLBR(16, 0, 12, 0);
constexpr gfx::Insets kMenuItemInnerPadding = gfx::Insets::VH(8, 16);
constexpr int kArrowIconSize = 20;
constexpr int kCheckmarkLabelSpacing = 16;
constexpr int kMaxMenuWidth = 168;
constexpr gfx::Vector2d kMenuOffset(0, 8);
constexpr int kMenuShadowElevation = 12;

class CheckboxMenuOptionGroup : public CheckboxGroup {
  METADATA_HEADER(CheckboxMenuOptionGroup, CheckboxGroup)

 public:
  CheckboxMenuOptionGroup()
      : CheckboxGroup(kMaxMenuWidth,
                      kMenuBorderInsets,
                      0,
                      kMenuItemInnerPadding,
                      kCheckmarkLabelSpacing) {
    GetViewAccessibility().SetProperties(ax::mojom::Role::kListBox);
  }

  // CheckboxGroup:
  Checkbox* AddButton(Checkbox::PressedCallback callback,
                      const std::u16string& label) override {
    auto* button = AddChildView(std::make_unique<Checkbox>(
        group_width_ - inside_border_insets_.width(), std::move(callback),
        label, kMenuItemInnerPadding, kCheckmarkLabelSpacing));
    button->set_delegate(this);
    buttons_.push_back(button);
    return button;
  }

  void GetAccessibleNodeData(ui::AXNodeData* node_data) override {
    CheckboxGroup::GetAccessibleNodeData(node_data);
    node_data->SetNameExplicitlyEmpty();
  }
};

BEGIN_METADATA(CheckboxMenuOptionGroup)
END_METADATA

}  // namespace

//------------------------------------------------------------------------------
// DropDownCheckbox::SelectionModel:
class DropDownCheckbox::SelectionModel : public ui::ListSelectionModel,
                                         public ui::ListModelObserver {
 public:
  SelectionModel() = default;
  SelectionModel(const SelectionModel&) = delete;
  SelectionModel& operator=(const SelectionModel&) = delete;
  ~SelectionModel() override = default;

  // ui::ListModelObserver:
  void ListItemsAdded(size_t start, size_t count) override {
    for (size_t i = 0; i < count; i++) {
      IncrementFrom(start + i);
    }
  }

  void ListItemsRemoved(size_t start, size_t count) override {
    for (size_t i = 0; i < count; i++) {
      DecrementFrom(start);
    }
  }

  void ListItemMoved(size_t index, size_t target_index) override {
    DecrementFrom(index);
    IncrementFrom(target_index);
  }

  void ListItemsChanged(size_t start, size_t count) override {}
};

//------------------------------------------------------------------------------
// DropDownCheckbox::MenuView:
class DropDownCheckbox::MenuView : public views::View {
  METADATA_HEADER(MenuView, views::View)

 public:
  explicit MenuView(DropDownCheckbox* drop_down_check_box)
      : drop_down_checkbox_(drop_down_check_box) {
    SetLayoutManager(std::make_unique<views::FillLayout>());
    menu_item_group_ =
        AddChildView(std::make_unique<CheckboxMenuOptionGroup>());
    UpdateMenuContent();
    SetBackground(views::CreateThemedRoundedRectBackground(
        kMenuBackgroundColorId, kMenuRoundedCorners));
    // Set border.
    SetBorder(std::make_unique<views::HighlightBorder>(
        kMenuRoundedCorners,
        views::HighlightBorder::Type::kHighlightBorderOnShadow));
  }
  MenuView(const MenuView&) = delete;
  MenuView& operator=(const MenuView&) = delete;
  ~MenuView() override = default;

  void UpdateMenuContent() {
    menu_item_group_->RemoveAllChildViews();

    // Build a checkbox group according to current list model.
    for (size_t i = 0; i < drop_down_checkbox_->model_->item_count(); i++) {
      auto* item = menu_item_group_->AddButton(
          base::BindRepeating(&DropDownCheckbox::MenuView::OnItemSelected,
                              base::Unretained(this), i),
          *drop_down_checkbox_->model_->GetItemAt(i));
      item->SetLabelStyle(TypographyToken::kCrosButton2);
      item->SetLabelColorId(kMenuTextColorId);
      item->SetSelected(drop_down_checkbox_->selection_model_->IsSelected(i));
    }
  }

 private:
  void OnItemSelected(size_t index) {
    auto* selection_model = drop_down_checkbox_->selection_model_.get();
    if (selection_model->IsSelected(index)) {
      selection_model->RemoveIndexFromSelection(index);
    } else {
      selection_model->AddIndexToSelection(index);
    }
  }

  const raw_ptr<DropDownCheckbox> drop_down_checkbox_;
  // Owned by this.
  raw_ptr<CheckboxMenuOptionGroup> menu_item_group_;
};

BEGIN_METADATA(DropDownCheckbox, MenuView)
END_METADATA

//------------------------------------------------------------------------------
// DropDownCheckbox::EventHandler:
// Handles the mouse and touch event that happens outside drop down checkbox and
// its drop down menu.
class DropDownCheckbox::EventHandler : public ui::EventHandler {
 public:
  explicit EventHandler(DropDownCheckbox* drop_down_checkbox)
      : drop_down_checkbox_(drop_down_checkbox) {
    aura::Env::GetInstance()->AddPreTargetHandler(
        this, ui::EventTarget::Priority::kSystem);
  }

  EventHandler(const EventHandler&) = delete;
  EventHandler& operator=(const EventHandler&) = delete;
  ~EventHandler() override {
    aura::Env::GetInstance()->RemovePreTargetHandler(this);
  }

  // ui::EventHandler:
  void OnMouseEvent(ui::MouseEvent* event) override { OnLocatedEvent(event); }

  void OnTouchEvent(ui::TouchEvent* event) override { OnLocatedEvent(event); }

 private:
  void OnLocatedEvent(ui::LocatedEvent* event) {
    // Close drop down menu if certain mouse or touch events happening outside
    // label button or menu area.
    if (!drop_down_checkbox_->IsMenuRunning()) {
      return;
    }

    // Get event location in screen.
    gfx::Point event_location = event->location();
    aura::Window* event_target = static_cast<aura::Window*>(event->target());
    wm::ConvertPointToScreen(event_target, &event_location);

    const bool event_in_drop_down_checkbox =
        drop_down_checkbox_->GetBoundsInScreen().Contains(event_location);
    const bool event_in_menu =
        drop_down_checkbox_->menu_->GetWindowBoundsInScreen().Contains(
            event_location);
    switch (event->type()) {
      case ui::EventType::kMousewheel:
        // Close menu if scrolling outside menu.
        if (!event_in_menu) {
          drop_down_checkbox_->CloseDropDownMenu();
        }
        break;
      case ui::EventType::kMousePressed:
      case ui::EventType::kTouchPressed:
        // Close menu if pressing outside menu and label button.
        if (!event_in_menu && !event_in_drop_down_checkbox) {
          event->StopPropagation();
          drop_down_checkbox_->CloseDropDownMenu();
        }
        break;
      default:
        break;
    }
  }

  const raw_ptr<DropDownCheckbox> drop_down_checkbox_;
};

//------------------------------------------------------------------------------
// DropDownCheckbox:
DropDownCheckbox::DropDownCheckbox(const std::u16string& title,
                                   DropDownCheckbox::ItemModel* model)
    : views::Button(
          base::BindRepeating(&DropDownCheckbox::OnDropDownCheckboxPressed,
                              base::Unretained(this))),
      model_(model),
      title_(AddChildView(std::make_unique<views::Label>(title))),
      drop_down_arrow_(AddChildView(std::make_unique<views::ImageView>(
          ui::ImageModel::FromVectorIcon(kDropDownArrowIcon,
                                         kInactiveTitleAndIconColorId,
                                         kArrowIconSize)))),
      selection_model_(std::make_unique<SelectionModel>()) {
  // Initialize the drop down menu with given model.
  CHECK(model_);
  model_->AddObserver(selection_model_.get());

  // Set up layout.
  SetLayoutManager(std::make_unique<views::FlexLayout>())
      ->SetInteriorMargin(kDropDownCheckboxBorderInsets);
  // Allow `title_` to shrink and elide, so that `drop_down_arrow_` on the
  // right always remains visible.
  title_->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero,
                               views::MaximumFlexSizeRule::kUnbounded));

  // Stylize the title.
  TypographyProvider::Get()->StyleLabel(TypographyToken::kCrosTitle1,
                                        *title_.get());
  title_->SetAutoColorReadabilityEnabled(false);
  title_->SetEnabledColorId(kInactiveTitleAndIconColorId);

  SetFocusBehavior(views::View::FocusBehavior::ALWAYS);

  SetPaintToLayer();
  layer()->SetFillsBoundsOpaquely(false);

  // Set up the ink drop.
  views::InstallRoundRectHighlightPathGenerator(
      this, gfx::Insets(), kDropDownCheckboxRoundedCorners);
  StyleUtil::SetUpInkDropForButton(this);
  views::FocusRing::Get(this)->SetProperty(views::kViewIgnoredByLayoutKey,
                                           /*ignored=*/true);

  event_handler_ = std::make_unique<EventHandler>(this);

  GetViewAccessibility().SetProperties(ax::mojom::Role::kPopUpButton);
}

DropDownCheckbox::~DropDownCheckbox() = default;

void DropDownCheckbox::SetSelectedAction(base::RepeatingClosure callback) {
  callback_ = std::move(callback);
}

DropDownCheckbox::SelectedIndices DropDownCheckbox::GetSelectedIndices() const {
  return selection_model_->selected_indices();
}

DropDownCheckbox::SelectedItems DropDownCheckbox::GetSelectedItems() const {
  SelectedItems selected_items;
  for (size_t index : GetSelectedIndices()) {
    selected_items.push_back(*model_->GetItemAt(index));
  }
  return selected_items;
}

bool DropDownCheckbox::IsMenuRunning() const {
  return !!menu_;
}

void DropDownCheckbox::SetCallback(PressedCallback callback) {
  NOTREACHED() << "Clients shouldn't modify this. Maybe you want to use "
                  "SetSelectedAction?";
}

void DropDownCheckbox::OnBoundsChanged(const gfx::Rect& previous_bounds) {
  // Move menu with combobox accordingly.
  if (menu_) {
    menu_->SetBounds(GetExpectedMenuBounds());
  }
}

void DropDownCheckbox::OnBlur() {
  if (menu_) {
    CloseDropDownMenu();
  }

  views::Button::OnBlur();
}

void DropDownCheckbox::AddedToWidget() {
  widget_observer_.Observe(GetWidget());
}

void DropDownCheckbox::RemovedFromWidget() {
  widget_observer_.Reset();
}

void DropDownCheckbox::Layout(PassKey) {
  LayoutSuperclass<views::Button>(this);
  views::FocusRing::Get(this)->DeprecatedLayoutImmediately();
}

void DropDownCheckbox::OnWidgetBoundsChanged(views::Widget* widget,
                                             const gfx::Rect& bounds) {
  if (menu_) {
    menu_->SetBounds(GetExpectedMenuBounds());
  }
}

gfx::Rect DropDownCheckbox::GetExpectedMenuBounds() const {
  CHECK(menu_view_);
  WorkAreaInsets* work_area =
      WorkAreaInsets::ForWindow(GetWidget()->GetNativeWindow());
  const gfx::Rect available_bounds = work_area->user_work_area_bounds();

  const gfx::Size preferred_size = menu_view_->GetPreferredSize();
  const gfx::Rect drop_down_checkbox_bounds = GetBoundsInScreen();

  // Decide whether to show the menu below (default) or above the label button:
  // if the menu fits below the label button, show it below.
  const int height_below = available_bounds.bottom() -
                           drop_down_checkbox_bounds.bottom() - kMenuOffset.y();
  bool show_below_drop_down_checkbox = height_below >= preferred_size.height();
  // If the drop down menu does not fit below label button, show it above the
  // label button of there is more space available above.
  if (!show_below_drop_down_checkbox) {
    const int height_above =
        drop_down_checkbox_bounds.y() - available_bounds.y() - kMenuOffset.y();
    show_below_drop_down_checkbox = height_below >= height_above;
  }

  gfx::Rect preferred_bounds =
      show_below_drop_down_checkbox
          ? gfx::Rect(drop_down_checkbox_bounds.bottom_left() + kMenuOffset,
                      preferred_size)
          : gfx::Rect(
                drop_down_checkbox_bounds.origin() +
                    gfx::Vector2d(kMenuOffset.x(),
                                  -preferred_size.height() - kMenuOffset.y()),
                preferred_size);

  // If the label button is offscreen, translate the preferred bounds to fit
  // available bounds.
  if (show_below_drop_down_checkbox &&
      drop_down_checkbox_bounds.bottom() < available_bounds.y()) {
    preferred_bounds.Offset(
        0, available_bounds.y() - drop_down_checkbox_bounds.bottom());
  } else if (!show_below_drop_down_checkbox &&
             drop_down_checkbox_bounds.y() > available_bounds.bottom()) {
    preferred_bounds.Offset(
        0, available_bounds.bottom() - drop_down_checkbox_bounds.y());
  }

  preferred_bounds.Intersect(available_bounds);
  return preferred_bounds;
}

void DropDownCheckbox::OnDropDownCheckboxPressed() {
  if (!GetEnabled()) {
    return;
  }

  if (menu_) {
    CloseDropDownMenu();
  } else if ((base::TimeTicks::Now() - closed_time_) >
             views::kMinimumTimeBetweenButtonClicks) {
    ShowDropDownMenu();
  }
}

void DropDownCheckbox::ShowDropDownMenu() {
  auto* widget = GetWidget();
  if (!widget) {
    return;
  }

  auto menu_view = std::make_unique<MenuView>(this);
  menu_view_ = menu_view.get();

  views::Widget::InitParams params(
      views::Widget::InitParams::NATIVE_WIDGET_OWNS_WIDGET,
      views::Widget::InitParams::TYPE_POPUP);
  params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent;
  params.shadow_type = views::Widget::InitParams::ShadowType::kDrop;
  params.shadow_elevation = kMenuShadowElevation;
  params.corner_radius = kMenuRoundedCorners;

  aura::Window* root_window = widget->GetNativeWindow()->GetRootWindow();
  params.parent = root_window->GetChildById(kShellWindowId_MenuContainer);
  params.bounds = GetExpectedMenuBounds();

  menu_ = std::make_unique<views::Widget>(std::move(params));
  menu_->SetContentsView(std::move(menu_view));
  menu_->Show();

  SetBackground(views::CreateThemedRoundedRectBackground(
      kDropDownCheckboxActiveColorId, kDropDownCheckboxRoundedCorners));
  title_->SetEnabledColorId(kActiveTitleAndIconColorId);
  drop_down_arrow_->SetImage(ui::ImageModel::FromVectorIcon(
      kDropDownArrowIcon, kActiveTitleAndIconColorId, kArrowIconSize));

  RequestFocus();
  NotifyAccessibilityEvent(ax::mojom::Event::kStateChanged, true);
}

void DropDownCheckbox::CloseDropDownMenu() {
  menu_view_ = nullptr;
  menu_.reset();

  closed_time_ = base::TimeTicks::Now();
  SetBackground(nullptr);
  title_->SetEnabledColorId(kInactiveTitleAndIconColorId);
  drop_down_arrow_->SetImage(ui::ImageModel::FromVectorIcon(
      kDropDownArrowIcon, kInactiveTitleAndIconColorId, kArrowIconSize));
  NotifyAccessibilityEvent(ax::mojom::Event::kStateChanged, true);
  OnPerformAction();
}

void DropDownCheckbox::OnPerformAction() {
  if (callback_) {
    callback_.Run();
  }
}

BEGIN_METADATA(DropDownCheckbox)
END_METADATA

}  // namespace ash