blob: defdcf540cfdb6e3cedfd38b423d6802241ca9e9 [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ui/views/controls/combobox/combobox.h"
#include "base/logging.h"
#include "build/build_config.h"
#include "ui/accessibility/ax_action_data.h"
#include "ui/accessibility/ax_node_data.h"
#include "ui/base/ime/input_method.h"
#include "ui/base/models/combobox_model.h"
#include "ui/base/models/combobox_model_observer.h"
#include "ui/base/models/menu_model.h"
#include "ui/events/event.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/scoped_canvas.h"
#include "ui/gfx/text_utils.h"
#include "ui/native_theme/native_theme.h"
#include "ui/views/animation/flood_fill_ink_drop_ripple.h"
#include "ui/views/animation/ink_drop_impl.h"
#include "ui/views/background.h"
#include "ui/views/controls/combobox/combobox_listener.h"
#include "ui/views/controls/focus_ring.h"
#include "ui/views/controls/focusable_border.h"
#include "ui/views/controls/menu/menu_config.h"
#include "ui/views/controls/menu/menu_runner.h"
#include "ui/views/controls/prefix_selector.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/mouse_constants.h"
#include "ui/views/style/platform_style.h"
#include "ui/views/style/typography.h"
#include "ui/views/widget/widget.h"
namespace views {
namespace {
// Used to indicate that no item is currently selected by the user.
constexpr int kNoSelection = -1;
SkColor GetTextColorForEnableState(const Combobox& combobox, bool enabled) {
SkColor color =
style::GetColor(combobox, style::CONTEXT_TEXTFIELD, style::STYLE_PRIMARY);
if (!enabled)
color = SkColorSetA(color, gfx::kDisabledControlAlpha);
return color;
}
// The transparent button which holds a button state but is not rendered.
class TransparentButton : public Button {
public:
explicit TransparentButton(ButtonListener* listener) : Button(listener) {
SetFocusBehavior(FocusBehavior::NEVER);
set_notify_action(PlatformStyle::kMenuNotifyActivationAction);
SetInkDropMode(InkDropMode::ON);
set_has_ink_drop_action_on_click(true);
}
~TransparentButton() override {}
bool OnMousePressed(const ui::MouseEvent& mouse_event) override {
#if !defined(OS_MACOSX)
// On Mac, comboboxes do not take focus on mouse click, but on other
// platforms they do.
parent()->RequestFocus();
#endif
return Button::OnMousePressed(mouse_event);
}
double GetAnimationValue() const {
return hover_animation().GetCurrentValue();
}
// Overridden from InkDropHost:
std::unique_ptr<InkDrop> CreateInkDrop() override {
std::unique_ptr<views::InkDropImpl> ink_drop = CreateDefaultInkDropImpl();
ink_drop->SetShowHighlightOnHover(false);
return std::move(ink_drop);
}
std::unique_ptr<InkDropRipple> CreateInkDropRipple() const override {
return std::unique_ptr<views::InkDropRipple>(
new views::FloodFillInkDropRipple(
size(), GetInkDropCenterBasedOnLastEvent(),
GetNativeTheme()->GetSystemColor(
ui::NativeTheme::kColorId_LabelEnabledColor),
ink_drop_visible_opacity()));
}
private:
DISALLOW_COPY_AND_ASSIGN(TransparentButton);
};
#if !defined(OS_MACOSX)
// Returns the next or previous valid index (depending on |increment|'s value).
// Skips separator or disabled indices. Returns -1 if there is no valid adjacent
// index.
int GetAdjacentIndex(ui::ComboboxModel* model, int increment, int index) {
DCHECK(increment == -1 || increment == 1);
index += increment;
while (index >= 0 && index < model->GetItemCount()) {
if (!model->IsItemSeparatorAt(index) || !model->IsItemEnabledAt(index))
return index;
index += increment;
}
return kNoSelection;
}
#endif
} // namespace
// static
const char Combobox::kViewClassName[] = "views/Combobox";
// Adapts a ui::ComboboxModel to a ui::MenuModel.
class Combobox::ComboboxMenuModel : public ui::MenuModel,
public ui::ComboboxModelObserver {
public:
ComboboxMenuModel(Combobox* owner, ui::ComboboxModel* model)
: owner_(owner), model_(model) {
model_->AddObserver(this);
}
~ComboboxMenuModel() override { model_->RemoveObserver(this); }
private:
bool UseCheckmarks() const {
return MenuConfig::instance().check_selected_combobox_item;
}
// Overridden from MenuModel:
bool HasIcons() const override { return false; }
int GetItemCount() const override { return model_->GetItemCount(); }
ItemType GetTypeAt(int index) const override {
if (model_->IsItemSeparatorAt(index))
return TYPE_SEPARATOR;
return UseCheckmarks() ? TYPE_CHECK : TYPE_COMMAND;
}
ui::MenuSeparatorType GetSeparatorTypeAt(int index) const override {
return ui::NORMAL_SEPARATOR;
}
int GetCommandIdAt(int index) const override {
// Define the id of the first item in the menu (since it needs to be > 0)
constexpr int kFirstMenuItemId = 1000;
return index + kFirstMenuItemId;
}
base::string16 GetLabelAt(int index) const override {
// Inserting the Unicode formatting characters if necessary so that the
// text is displayed correctly in right-to-left UIs.
base::string16 text = model_->GetItemAt(index);
base::i18n::AdjustStringForLocaleDirection(&text);
return text;
}
bool IsItemDynamicAt(int index) const override { return true; }
const gfx::FontList* GetLabelFontListAt(int index) const override {
return &owner_->GetFontList();
}
bool GetAcceleratorAt(int index,
ui::Accelerator* accelerator) const override {
return false;
}
bool IsItemCheckedAt(int index) const override {
return UseCheckmarks() && index == owner_->selected_index_;
}
int GetGroupIdAt(int index) const override { return -1; }
bool GetIconAt(int index, gfx::Image* icon) override { return false; }
ui::ButtonMenuItemModel* GetButtonMenuItemAt(int index) const override {
return nullptr;
}
bool IsEnabledAt(int index) const override {
return model_->IsItemEnabledAt(index);
}
void ActivatedAt(int index) override {
owner_->selected_index_ = index;
owner_->OnPerformAction();
}
void ActivatedAt(int index, int event_flags) override { ActivatedAt(index); }
MenuModel* GetSubmenuModelAt(int index) const override { return nullptr; }
void SetMenuModelDelegate(
ui::MenuModelDelegate* menu_model_delegate) override {}
ui::MenuModelDelegate* GetMenuModelDelegate() const override {
return nullptr;
}
// Overridden from ComboboxModelObserver:
void OnComboboxModelChanged(ui::ComboboxModel* model) override {
owner_->ModelChanged();
}
Combobox* owner_; // Weak. Owns this.
ui::ComboboxModel* model_; // Weak.
DISALLOW_COPY_AND_ASSIGN(ComboboxMenuModel);
};
////////////////////////////////////////////////////////////////////////////////
// Combobox, public:
Combobox::Combobox(std::unique_ptr<ui::ComboboxModel> model,
int text_context,
int text_style)
: Combobox(model.get(), text_context, text_style) {
owned_model_ = std::move(model);
}
Combobox::Combobox(ui::ComboboxModel* model, int text_context, int text_style)
: model_(model),
text_context_(text_context),
text_style_(text_style),
listener_(nullptr),
selected_index_(model_->GetDefaultIndex()),
invalid_(false),
menu_model_(new ComboboxMenuModel(this, model)),
arrow_button_(new TransparentButton(this)),
size_to_largest_label_(true) {
ModelChanged();
#if defined(OS_MACOSX)
SetFocusBehavior(FocusBehavior::ACCESSIBLE_ONLY);
#else
SetFocusBehavior(FocusBehavior::ALWAYS);
#endif
UpdateBorder();
arrow_button_->SetVisible(true);
AddChildView(arrow_button_);
// A layer is applied to make sure that canvas bounds are snapped to pixel
// boundaries (for the sake of drawing the arrow).
SetPaintToLayer();
layer()->SetFillsBoundsOpaquely(false);
focus_ring_ = FocusRing::Install(this);
}
Combobox::~Combobox() {
if (GetInputMethod() && selector_.get()) {
// Combobox should have been blurred before destroy.
DCHECK(selector_.get() != GetInputMethod()->GetTextInputClient());
}
}
const gfx::FontList& Combobox::GetFontList() const {
return style::GetFont(text_context_, text_style_);
}
void Combobox::ModelChanged() {
// If the selection is no longer valid (or the model is empty), restore the
// default index.
if (selected_index_ >= model_->GetItemCount() ||
model_->GetItemCount() == 0 ||
model_->IsItemSeparatorAt(selected_index_)) {
selected_index_ = model_->GetDefaultIndex();
}
content_size_ = GetContentSize();
PreferredSizeChanged();
SchedulePaint();
}
void Combobox::SetSelectedIndex(int index) {
selected_index_ = index;
if (size_to_largest_label_) {
SchedulePaint();
} else {
content_size_ = GetContentSize();
PreferredSizeChanged();
}
}
bool Combobox::SelectValue(const base::string16& value) {
for (int i = 0; i < model()->GetItemCount(); ++i) {
if (value == model()->GetItemAt(i)) {
SetSelectedIndex(i);
return true;
}
}
return false;
}
void Combobox::SetTooltipText(const base::string16& tooltip_text) {
arrow_button_->SetTooltipText(tooltip_text);
if (accessible_name_.empty())
accessible_name_ = tooltip_text;
}
void Combobox::SetAccessibleName(const base::string16& name) {
accessible_name_ = name;
}
void Combobox::SetInvalid(bool invalid) {
if (invalid == invalid_)
return;
invalid_ = invalid;
if (focus_ring_)
focus_ring_->SetInvalid(invalid);
UpdateBorder();
SchedulePaint();
}
void Combobox::Layout() {
View::Layout();
arrow_button_->SetBounds(0, 0, width(), height());
}
void Combobox::OnNativeThemeChanged(const ui::NativeTheme* theme) {
SetBackground(
CreateBackgroundFromPainter(Painter::CreateSolidRoundRectPainter(
theme->GetSystemColor(
ui::NativeTheme::kColorId_TextfieldDefaultBackground),
FocusableBorder::kCornerRadiusDp)));
}
int Combobox::GetRowCount() {
return model()->GetItemCount();
}
int Combobox::GetSelectedRow() {
return selected_index_;
}
void Combobox::SetSelectedRow(int row) {
int prev_index = selected_index_;
SetSelectedIndex(row);
if (selected_index_ != prev_index)
OnPerformAction();
}
base::string16 Combobox::GetTextForRow(int row) {
return model()->IsItemSeparatorAt(row) ? base::string16() :
model()->GetItemAt(row);
}
////////////////////////////////////////////////////////////////////////////////
// Combobox, View overrides:
gfx::Size Combobox::CalculatePreferredSize() const {
// Limit how small a combobox can be.
constexpr int kMinComboboxWidth = 25;
// The preferred size will drive the local bounds which in turn is used to set
// the minimum width for the dropdown list.
gfx::Insets insets = GetInsets();
const LayoutProvider* provider = LayoutProvider::Get();
insets += gfx::Insets(
provider->GetDistanceMetric(DISTANCE_CONTROL_VERTICAL_TEXT_PADDING),
provider->GetDistanceMetric(DISTANCE_TEXTFIELD_HORIZONTAL_TEXT_PADDING));
int total_width = std::max(kMinComboboxWidth, content_size_.width()) +
insets.width() + GetArrowContainerWidth();
return gfx::Size(total_width, content_size_.height() + insets.height());
}
const char* Combobox::GetClassName() const {
return kViewClassName;
}
bool Combobox::SkipDefaultKeyEventProcessing(const ui::KeyEvent& e) {
// Escape should close the drop down list when it is active, not host UI.
if (e.key_code() != ui::VKEY_ESCAPE ||
e.IsShiftDown() || e.IsControlDown() || e.IsAltDown()) {
return false;
}
return !!menu_runner_;
}
bool Combobox::OnKeyPressed(const ui::KeyEvent& e) {
// TODO(oshima): handle IME.
DCHECK_EQ(e.type(), ui::ET_KEY_PRESSED);
DCHECK_GE(selected_index_, 0);
DCHECK_LT(selected_index_, model()->GetItemCount());
if (selected_index_ < 0 || selected_index_ > model()->GetItemCount())
selected_index_ = 0;
bool show_menu = false;
int new_index = kNoSelection;
switch (e.key_code()) {
#if defined(OS_MACOSX)
case ui::VKEY_DOWN:
case ui::VKEY_UP:
case ui::VKEY_SPACE:
case ui::VKEY_HOME:
case ui::VKEY_END:
// On Mac, navigation keys should always just show the menu first.
show_menu = true;
break;
#else
// Show the menu on F4 without modifiers.
case ui::VKEY_F4:
if (e.IsAltDown() || e.IsAltGrDown() || e.IsControlDown())
return false;
show_menu = true;
break;
// Move to the next item if any, or show the menu on Alt+Down like Windows.
case ui::VKEY_DOWN:
if (e.IsAltDown())
show_menu = true;
else
new_index = GetAdjacentIndex(model(), 1, selected_index_);
break;
// Move to the end of the list.
case ui::VKEY_END:
case ui::VKEY_NEXT: // Page down.
new_index = GetAdjacentIndex(model(), -1, model()->GetItemCount());
break;
// Move to the beginning of the list.
case ui::VKEY_HOME:
case ui::VKEY_PRIOR: // Page up.
new_index = GetAdjacentIndex(model(), 1, -1);
break;
// Move to the previous item if any.
case ui::VKEY_UP:
new_index = GetAdjacentIndex(model(), -1, selected_index_);
break;
case ui::VKEY_RETURN:
case ui::VKEY_SPACE:
show_menu = true;
break;
#endif // OS_MACOSX
default:
return false;
}
if (show_menu) {
ShowDropDownMenu(ui::MENU_SOURCE_KEYBOARD);
} else if (new_index != selected_index_ && new_index != kNoSelection) {
DCHECK(!model()->IsItemSeparatorAt(new_index));
selected_index_ = new_index;
OnPerformAction();
}
return true;
}
void Combobox::OnPaint(gfx::Canvas* canvas) {
OnPaintBackground(canvas);
PaintText(canvas);
OnPaintBorder(canvas);
}
void Combobox::OnFocus() {
if (GetInputMethod())
GetInputMethod()->SetFocusedTextInputClient(GetPrefixSelector());
View::OnFocus();
// Border renders differently when focused.
SchedulePaint();
}
void Combobox::OnBlur() {
if (GetInputMethod())
GetInputMethod()->DetachTextInputClient(GetPrefixSelector());
if (selector_)
selector_->OnViewBlur();
// Border renders differently when focused.
SchedulePaint();
}
void Combobox::GetAccessibleNodeData(ui::AXNodeData* node_data) {
// ax::mojom::Role::kComboBox is for UI elements with a dropdown and
// an editable text field, which views::Combobox does not have. Use
// ax::mojom::Role::kPopUpButton to match an HTML <select> element.
node_data->role = ax::mojom::Role::kPopUpButton;
node_data->SetName(accessible_name_);
node_data->SetValue(model_->GetItemAt(selected_index_));
if (enabled()) {
node_data->SetDefaultActionVerb(ax::mojom::DefaultActionVerb::kOpen);
}
node_data->AddIntAttribute(ax::mojom::IntAttribute::kPosInSet,
selected_index_);
node_data->AddIntAttribute(ax::mojom::IntAttribute::kSetSize,
model_->GetItemCount());
}
bool Combobox::HandleAccessibleAction(const ui::AXActionData& action_data) {
// The action handling in View would generate a mouse event and send it to
// |this|. However, mouse events for Combobox are handled by |arrow_button_|,
// which is hidden from the a11y tree (so can't expose actions). Rather than
// forwarding ax::mojom::Action::kDoDefault to View and then forwarding the
// mouse event it generates to |arrow_button_| to have it forward back to
// |this| (as its ButtonListener), just handle the action explicitly here and
// bypass View.
if (enabled() && action_data.action == ax::mojom::Action::kDoDefault) {
ShowDropDownMenu(ui::MENU_SOURCE_KEYBOARD);
return true;
}
return View::HandleAccessibleAction(action_data);
}
void Combobox::ButtonPressed(Button* sender, const ui::Event& event) {
if (!enabled())
return;
// TODO(hajimehoshi): Fix the problem that the arrow button blinks when
// cliking this while the dropdown menu is opened.
const base::TimeDelta delta = base::TimeTicks::Now() - closed_time_;
if (delta.InMilliseconds() <= kMinimumMsBetweenButtonClicks)
return;
ui::MenuSourceType source_type = ui::MENU_SOURCE_MOUSE;
if (event.IsKeyEvent())
source_type = ui::MENU_SOURCE_KEYBOARD;
else if (event.IsGestureEvent() || event.IsTouchEvent())
source_type = ui::MENU_SOURCE_TOUCH;
ShowDropDownMenu(source_type);
}
void Combobox::UpdateBorder() {
std::unique_ptr<FocusableBorder> border(new FocusableBorder());
if (invalid_)
border->SetColorId(ui::NativeTheme::kColorId_AlertSeverityHigh);
SetBorder(std::move(border));
}
void Combobox::AdjustBoundsForRTLUI(gfx::Rect* rect) const {
rect->set_x(GetMirroredXForRect(*rect));
}
void Combobox::PaintText(gfx::Canvas* canvas) {
gfx::Insets insets = GetInsets();
insets += gfx::Insets(0, LayoutProvider::Get()->GetDistanceMetric(
DISTANCE_TEXTFIELD_HORIZONTAL_TEXT_PADDING));
gfx::ScopedCanvas scoped_canvas(canvas);
canvas->ClipRect(GetContentsBounds());
int x = insets.left();
int y = insets.top();
int text_height = height() - insets.height();
SkColor text_color = GetTextColorForEnableState(*this, enabled());
DCHECK_GE(selected_index_, 0);
DCHECK_LT(selected_index_, model()->GetItemCount());
if (selected_index_ < 0 || selected_index_ > model()->GetItemCount())
selected_index_ = 0;
base::string16 text = model()->GetItemAt(selected_index_);
int disclosure_arrow_offset = width() - GetArrowContainerWidth();
const gfx::FontList& font_list = GetFontList();
int text_width = gfx::GetStringWidth(text, font_list);
if ((text_width + insets.width()) > disclosure_arrow_offset)
text_width = disclosure_arrow_offset - insets.width();
gfx::Rect text_bounds(x, y, text_width, text_height);
AdjustBoundsForRTLUI(&text_bounds);
canvas->DrawStringRect(text, font_list, text_color, text_bounds);
gfx::Rect arrow_bounds(disclosure_arrow_offset, 0, GetArrowContainerWidth(),
height());
arrow_bounds.ClampToCenteredSize(ArrowSize());
AdjustBoundsForRTLUI(&arrow_bounds);
{
// Since this is a core piece of UI and vector icons don't handle fractional
// scale factors particularly well, manually draw an arrow and make sure it
// looks good at all scale factors.
float dsf = canvas->UndoDeviceScaleFactor();
SkScalar x = std::ceil(arrow_bounds.x() * dsf);
SkScalar y = std::ceil(arrow_bounds.y() * dsf);
SkScalar height = std::floor(arrow_bounds.height() * dsf);
SkPath path;
// This epsilon makes sure that all the aliasing pixels are slightly more
// than half full. Otherwise, rounding issues cause some to be considered
// slightly less than half full and come out a little lighter.
const SkScalar kEpsilon = 0.0001f;
path.moveTo(x - kEpsilon, y);
path.rLineTo(height, height);
path.rLineTo(2 * kEpsilon, 0);
path.rLineTo(height, -height);
path.close();
cc::PaintFlags flags;
flags.setColor(text_color);
flags.setAntiAlias(true);
canvas->DrawPath(path, flags);
}
}
void Combobox::ShowDropDownMenu(ui::MenuSourceType source_type) {
// Menu border widths.
constexpr int kMenuBorderWidthLeft = 1;
constexpr int kMenuBorderWidthTop = 1;
constexpr int kMenuBorderWidthRight = 1;
gfx::Rect lb = GetLocalBounds();
gfx::Point menu_position(lb.origin());
// Inset the menu's requested position so the border of the menu lines up
// with the border of the combobox.
menu_position.set_x(menu_position.x() + kMenuBorderWidthLeft);
menu_position.set_y(menu_position.y() + kMenuBorderWidthTop);
lb.set_width(lb.width() - (kMenuBorderWidthLeft + kMenuBorderWidthRight));
View::ConvertPointToScreen(this, &menu_position);
gfx::Rect bounds(menu_position, lb.size());
Button::ButtonState original_state = arrow_button_->state();
arrow_button_->SetState(Button::STATE_PRESSED);
// Allow |menu_runner_| to be set by the testing API, but if this method is
// ever invoked recursively, ensure the old menu is closed.
if (!menu_runner_ || menu_runner_->IsRunning()) {
menu_runner_.reset(
new MenuRunner(menu_model_.get(), MenuRunner::COMBOBOX,
base::Bind(&Combobox::OnMenuClosed,
base::Unretained(this), original_state)));
}
menu_runner_->RunMenuAt(GetWidget(), nullptr, bounds, MENU_ANCHOR_TOPLEFT,
source_type);
}
void Combobox::OnMenuClosed(Button::ButtonState original_button_state) {
menu_runner_.reset();
arrow_button_->SetState(original_button_state);
closed_time_ = base::TimeTicks::Now();
}
void Combobox::OnPerformAction() {
NotifyAccessibilityEvent(ax::mojom::Event::kValueChanged, true);
SchedulePaint();
if (listener_)
listener_->OnPerformAction(this);
// Note |this| may be deleted by |listener_|.
}
gfx::Size Combobox::ArrowSize() const {
return gfx::Size(8, 4);
}
gfx::Size Combobox::GetContentSize() const {
const gfx::FontList& font_list = GetFontList();
int width = 0;
for (int i = 0; i < model()->GetItemCount(); ++i) {
if (model_->IsItemSeparatorAt(i))
continue;
if (size_to_largest_label_ || i == selected_index_) {
width = std::max(
width, gfx::GetStringWidth(menu_model_->GetLabelAt(i), font_list));
}
}
return gfx::Size(width, font_list.GetHeight());
}
PrefixSelector* Combobox::GetPrefixSelector() {
if (!selector_)
selector_.reset(new PrefixSelector(this, this));
return selector_.get();
}
int Combobox::GetArrowContainerWidth() const {
constexpr int kPaddingWidth = 8;
return ArrowSize().width() + kPaddingWidth * 2;
}
} // namespace views