// 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 #include #include "base/logging.h" #include "base/macros.h" #include "build/build_config.h" #include "ui/accessibility/ax_action_data.h" #include "ui/accessibility/ax_node_data.h" #include "ui/base/default_style.h" #include "ui/base/ime/input_method.h" #include "ui/base/material_design/material_design_controller.h" #include "ui/base/models/combobox_model_observer.h" #include "ui/base/resource/resource_bundle.h" #include "ui/events/event.h" #include "ui/gfx/animation/throb_animation.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/common_theme.h" #include "ui/native_theme/native_theme.h" #include "ui/native_theme/native_theme_aura.h" #include "ui/resources/grit/ui_resources.h" #include "ui/views/animation/flood_fill_ink_drop_ripple.h" #include "ui/views/animation/ink_drop_highlight.h" #include "ui/views/animation/ink_drop_impl.h" #include "ui/views/background.h" #include "ui/views/controls/button/button.h" #include "ui/views/controls/button/label_button.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/controls/textfield/textfield.h" #include "ui/views/layout/layout_provider.h" #include "ui/views/mouse_constants.h" #include "ui/views/painter.h" #include "ui/views/resources/grit/views_resources.h" #include "ui/views/style/platform_style.h" #include "ui/views/widget/widget.h" namespace views { namespace { // STYLE_ACTION arrow container padding widths. const int kActionLeftPadding = 12; const int kActionRightPadding = 11; // Menu border widths const int kMenuBorderWidthLeft = 1; const int kMenuBorderWidthTop = 1; const int kMenuBorderWidthRight = 1; // Limit how small a combobox can be. const int kMinComboboxWidth = 25; // Define the id of the first item in the menu (since it needs to be > 0) const int kFirstMenuItemId = 1000; // Used to indicate that no item is currently selected by the user. const int kNoSelection = -1; const int kBodyButtonImages[] = IMAGE_GRID(IDR_COMBOBOX_BUTTON); const int kHoveredBodyButtonImages[] = IMAGE_GRID(IDR_COMBOBOX_BUTTON_H); const int kPressedBodyButtonImages[] = IMAGE_GRID(IDR_COMBOBOX_BUTTON_P); const int kFocusedBodyButtonImages[] = IMAGE_GRID(IDR_COMBOBOX_BUTTON_F); const int kFocusedHoveredBodyButtonImages[] = IMAGE_GRID(IDR_COMBOBOX_BUTTON_F_H); const int kFocusedPressedBodyButtonImages[] = IMAGE_GRID(IDR_COMBOBOX_BUTTON_F_P); #define MENU_IMAGE_GRID(x) { \ x ## _MENU_TOP, x ## _MENU_CENTER, x ## _MENU_BOTTOM, } const int kMenuButtonImages[] = MENU_IMAGE_GRID(IDR_COMBOBOX_BUTTON); const int kHoveredMenuButtonImages[] = MENU_IMAGE_GRID(IDR_COMBOBOX_BUTTON_H); const int kPressedMenuButtonImages[] = MENU_IMAGE_GRID(IDR_COMBOBOX_BUTTON_P); const int kFocusedMenuButtonImages[] = MENU_IMAGE_GRID(IDR_COMBOBOX_BUTTON_F); const int kFocusedHoveredMenuButtonImages[] = MENU_IMAGE_GRID(IDR_COMBOBOX_BUTTON_F_H); const int kFocusedPressedMenuButtonImages[] = MENU_IMAGE_GRID(IDR_COMBOBOX_BUTTON_F_P); #undef MENU_IMAGE_GRID bool UseMd() { return ui::MaterialDesignController::IsSecondaryUiMaterial(); } SkColor GetTextColorForEnableState(const Combobox& combobox, bool enabled) { return style::GetColor( combobox, style::CONTEXT_TEXTFIELD, enabled ? style::STYLE_PRIMARY : style::STYLE_DISABLED); } gfx::Rect PositionArrowWithinContainer(const gfx::Rect& container_bounds, const gfx::Size& arrow_size, Combobox::Style style) { gfx::Rect bounds(container_bounds); if (style == Combobox::STYLE_ACTION) { // This positions the arrow horizontally. The later call to // ClampToCenteredSize will position it vertically without touching the // horizontal position. bounds.Inset(kActionLeftPadding, 0, kActionRightPadding, 0); DCHECK_EQ(bounds.width(), arrow_size.width()); } bounds.ClampToCenteredSize(arrow_size); return bounds; } // The transparent button which holds a button state but is not rendered. class TransparentButton : public Button { public: TransparentButton(ButtonListener* listener, bool animate_state_change) : Button(listener) { set_animate_on_state_change(animate_state_change); if (animate_state_change) SetAnimationDuration(LabelButton::kHoverAnimationDurationMs); SetFocusBehavior(FocusBehavior::NEVER); set_notify_action(PlatformStyle::kMenuNotifyActivationAction); if (UseMd()) { 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 CreateInkDrop() override { std::unique_ptr ink_drop = CreateDefaultInkDropImpl(); ink_drop->SetShowHighlightOnHover(false); return std::move(ink_drop); } std::unique_ptr CreateInkDropRipple() const override { return std::unique_ptr( 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 // Returns the image resource ids of an array for the body button. // // TODO(hajimehoshi): This function should return the images for the 'disabled' // status. (crbug/270052) const int* GetBodyButtonImageIds(bool focused, Button::ButtonState state, size_t* num) { DCHECK(num); *num = 9; switch (state) { case Button::STATE_DISABLED: return focused ? kFocusedBodyButtonImages : kBodyButtonImages; case Button::STATE_NORMAL: return focused ? kFocusedBodyButtonImages : kBodyButtonImages; case Button::STATE_HOVERED: return focused ? kFocusedHoveredBodyButtonImages : kHoveredBodyButtonImages; case Button::STATE_PRESSED: return focused ? kFocusedPressedBodyButtonImages : kPressedBodyButtonImages; default: NOTREACHED(); } return NULL; } // Returns the image resource ids of an array for the menu button. const int* GetMenuButtonImageIds(bool focused, Button::ButtonState state, size_t* num) { DCHECK(num); *num = 3; switch (state) { case Button::STATE_DISABLED: return focused ? kFocusedMenuButtonImages : kMenuButtonImages; case Button::STATE_NORMAL: return focused ? kFocusedMenuButtonImages : kMenuButtonImages; case Button::STATE_HOVERED: return focused ? kFocusedHoveredMenuButtonImages : kHoveredMenuButtonImages; case Button::STATE_PRESSED: return focused ? kFocusedPressedMenuButtonImages : kPressedMenuButtonImages; default: NOTREACHED(); } return NULL; } // Returns the images for the menu buttons. std::vector GetMenuButtonImages( bool focused, Button::ButtonState state) { const int* ids; size_t num_ids; ids = GetMenuButtonImageIds(focused, state, &num_ids); std::vector images; images.reserve(num_ids); ui::ResourceBundle& rb = ui::ResourceBundle::GetSharedInstance(); for (size_t i = 0; i < num_ids; i++) images.push_back(rb.GetImageSkiaNamed(ids[i])); return images; } // Paints three images in a column at the given location. The center image is // stretched so as to fit the given height. void PaintImagesVertically(gfx::Canvas* canvas, const gfx::ImageSkia& top_image, const gfx::ImageSkia& center_image, const gfx::ImageSkia& bottom_image, int x, int y, int width, int height) { canvas->DrawImageInt(top_image, 0, 0, top_image.width(), top_image.height(), x, y, width, top_image.height(), false); y += top_image.height(); int center_height = height - top_image.height() - bottom_image.height(); canvas->DrawImageInt(center_image, 0, 0, center_image.width(), center_image.height(), x, y, width, center_height, false); y += center_height; canvas->DrawImageInt(bottom_image, 0, 0, bottom_image.width(), bottom_image.height(), x, y, width, bottom_image.height(), false); } // Paints the arrow button. void PaintArrowButton( gfx::Canvas* canvas, const std::vector& arrow_button_images, int x, int height) { PaintImagesVertically(canvas, *arrow_button_images[0], *arrow_button_images[1], *arrow_button_images[2], x, 0, arrow_button_images[0]->width(), height); } } // 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 owner_->style_ != STYLE_ACTION && 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)) { // In action menus, disallow , , ... since that would put // a separator at the top of the menu. DCHECK(index != 1 || owner_->style_ != STYLE_ACTION); 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 { 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 &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); } bool IsVisibleAt(int index) const override { // When STYLE_ACTION is used, the first item is not added to the menu. It is // assumed that the first item is always selected and rendered on the top of // the action button. return index > 0 || owner_->style_ != STYLE_ACTION; } void HighlightChangedTo(int index) override {} 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 model, Style style) : Combobox(model.get(), style) { owned_model_ = std::move(model); } Combobox::Combobox(ui::ComboboxModel* model, Style style) : model_(model), style_(style), listener_(nullptr), selected_index_(style == STYLE_ACTION ? 0 : model_->GetDefaultIndex()), invalid_(false), menu_model_(new ComboboxMenuModel(this, model)), text_button_(new TransparentButton(this, style_ == STYLE_ACTION)), arrow_button_(new TransparentButton(this, style_ == STYLE_ACTION)), size_to_largest_label_(style_ == STYLE_NORMAL), weak_ptr_factory_(this) { ModelChanged(); #if defined(OS_MACOSX) SetFocusBehavior(FocusBehavior::ACCESSIBLE_ONLY); #else SetFocusBehavior(FocusBehavior::ALWAYS); #endif UpdateBorder(); // Initialize the button images. Button::ButtonState button_states[] = { Button::STATE_DISABLED, Button::STATE_NORMAL, Button::STATE_HOVERED, Button::STATE_PRESSED, }; for (int i = 0; i < 2; i++) { for (size_t state_index = 0; state_index < arraysize(button_states); state_index++) { Button::ButtonState state = button_states[state_index]; size_t num; bool focused = !!i; const int* ids = GetBodyButtonImageIds(focused, state, &num); body_button_painters_[focused][state] = Painter::CreateImageGridPainter(ids); menu_button_images_[focused][state] = GetMenuButtonImages(focused, state); } } text_button_->SetVisible(true); arrow_button_->SetVisible(true); AddChildView(text_button_); 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). if (UseMd()) { SetPaintToLayer(); layer()->SetFillsBoundsOpaquely(false); } else { arrow_image_ = *ui::ResourceBundle::GetSharedInstance().GetImageSkiaNamed( IDR_MENU_DROPARROW); } } Combobox::~Combobox() { if (GetInputMethod() && selector_.get()) { // Combobox should have been blurred before destroy. DCHECK(selector_.get() != GetInputMethod()->GetTextInputClient()); } } // static const gfx::FontList& Combobox::GetFontList() { ui::ResourceBundle& rb = ui::ResourceBundle::GetSharedInstance(); return rb.GetFontListWithDelta(ui::kLabelFontSizeDelta); } 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) { if (style_ == STYLE_ACTION) return; selected_index_ = index; if (size_to_largest_label_) { SchedulePaint(); } else { content_size_ = GetContentSize(); PreferredSizeChanged(); } } bool Combobox::SelectValue(const base::string16& value) { if (style_ == STYLE_ACTION) return false; 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 (HasFocus() && UseMd()) { FocusRing::Install(this, invalid_ ? ui::NativeTheme::kColorId_AlertSeverityHigh : ui::NativeTheme::kColorId_NumColors); } UpdateBorder(); SchedulePaint(); } void Combobox::Layout() { View::Layout(); int text_button_width = 0; int arrow_button_width = 0; switch (style_) { case STYLE_NORMAL: { arrow_button_width = width(); break; } case STYLE_ACTION: { arrow_button_width = GetArrowContainerWidth(); text_button_width = width() - arrow_button_width; break; } } int arrow_button_x = std::max(0, text_button_width); text_button_->SetBounds(0, 0, std::max(0, text_button_width), height()); arrow_button_->SetBounds(arrow_button_x, 0, arrow_button_width, height()); } void Combobox::OnNativeThemeChanged(const ui::NativeTheme* theme) { if (!UseMd()) return; 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 { // 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_SPACE: if (style_ == STYLE_ACTION) { // When pressing space, the click event will be raised after the key is // released. text_button_->SetState(Button::STATE_PRESSED); } else { show_menu = true; } break; case ui::VKEY_RETURN: if (style_ == STYLE_ACTION) OnPerformAction(); else 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 && style_ != STYLE_ACTION) { DCHECK(!model()->IsItemSeparatorAt(new_index)); selected_index_ = new_index; OnPerformAction(); } return true; } bool Combobox::OnKeyReleased(const ui::KeyEvent& e) { if (style_ != STYLE_ACTION) return false; // crbug.com/127520 if (e.key_code() == ui::VKEY_SPACE && style_ == STYLE_ACTION && text_button_->state() == Button::STATE_PRESSED) OnPerformAction(); return false; } void Combobox::OnPaint(gfx::Canvas* canvas) { switch (style_) { case STYLE_NORMAL: { OnPaintBackground(canvas); PaintText(canvas); OnPaintBorder(canvas); break; } case STYLE_ACTION: { PaintButtons(canvas); PaintText(canvas); break; } } } void Combobox::OnFocus() { if (GetInputMethod()) GetInputMethod()->SetFocusedTextInputClient(GetPrefixSelector()); View::OnFocus(); // Border renders differently when focused. SchedulePaint(); if (UseMd()) { FocusRing::Install(this, invalid_ ? ui::NativeTheme::kColorId_AlertSeverityHigh : ui::NativeTheme::kColorId_NumColors); } } void Combobox::OnBlur() { if (GetInputMethod()) GetInputMethod()->DetachTextInputClient(GetPrefixSelector()); if (selector_) selector_->OnViewBlur(); // Border renders differently when focused. SchedulePaint(); if (UseMd()) FocusRing::Uninstall(this); } 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