blob: 9d04ac5f8c552d2563dbece1c16f023153c1c8de [file] [log] [blame]
// Copyright 2017 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/autofill/popup/popup_view_views.h"
#include <algorithm>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
#include "base/containers/contains.h"
#include "base/feature_list.h"
#include "base/memory/raw_ptr.h"
#include "base/time/time.h"
#include "build/build_config.h"
#include "chrome/browser/autofill/autofill_popup_controller_utils.h"
#include "chrome/browser/platform_util.h"
#include "chrome/browser/ui/autofill/autofill_popup_controller.h"
#include "chrome/browser/ui/browser_element_identifiers.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/passwords/ui_utils.h"
#include "chrome/browser/ui/views/autofill/popup/popup_base_view.h"
#include "chrome/browser/ui/views/autofill/popup/popup_row_view.h"
#include "chrome/browser/ui/views/autofill/popup/popup_separator_view.h"
#include "chrome/browser/ui/views/autofill/popup/popup_view_utils.h"
#include "chrome/browser/ui/views/autofill/popup/popup_warning_view.h"
#include "chrome/browser/ui/views/chrome_layout_provider.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "components/autofill/core/browser/autofill_experiments.h"
#include "components/autofill/core/browser/data_model/credit_card.h"
#include "components/autofill/core/browser/metrics/autofill_metrics.h"
#include "components/autofill/core/browser/ui/popup_item_ids.h"
#include "components/autofill/core/browser/ui/popup_types.h"
#include "components/autofill/core/browser/ui/suggestion.h"
#include "components/autofill/core/common/autofill_features.h"
#include "components/autofill/core/common/autofill_payments_features.h"
#include "components/feature_engagement/public/feature_constants.h"
#include "components/strings/grit/components_strings.h"
#include "components/user_education/common/feature_promo_controller.h"
#include "third_party/abseil-cpp/absl/types/variant.h"
#include "third_party/blink/public/common/input/web_input_event.h"
#include "ui/accessibility/ax_node_data.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/ui_base_features.h"
#include "ui/color/color_id.h"
#include "ui/events/keycodes/keyboard_codes.h"
#include "ui/gfx/geometry/rect_conversions.h"
#include "ui/gfx/geometry/rounded_corners_f.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/view.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/widget/widget.h"
using views::BubbleBorder;
namespace autofill {
namespace {
// By spec, dropdowns should always have a width which is a multiple of 12.
constexpr int kAutofillPopupWidthMultiple = 12;
// The minimum width should exceed the maximum size of a cursor, which is 128
// (see crbug.com/1434330).
constexpr int kAutofillPopupMinWidth = kAutofillPopupWidthMultiple * 13;
static_assert(kAutofillPopupMinWidth > 128);
// TODO(crbug.com/831603): move handling the max width to the base class.
constexpr int kAutofillPopupMaxWidth = kAutofillPopupWidthMultiple * 38;
int GetContentsVerticalPadding() {
return ChromeLayoutProvider::Get()->GetDistanceMetric(
DISTANCE_CONTENT_LIST_VERTICAL_SINGLE);
}
// Returns true if the item at `line_number` is a footer item.
bool IsFooterItem(const std::vector<Suggestion>& suggestions,
size_t line_number) {
if (line_number >= suggestions.size()) {
return false;
}
// Separators are a special case: They belong into the footer iff the next
// item exists and is a footer item.
PopupItemId popup_item_id = suggestions[line_number].popup_item_id;
return popup_item_id == PopupItemId::kSeparator
? IsFooterItem(suggestions, line_number + 1)
: IsFooterFrontendId(popup_item_id);
}
} // namespace
PopupViewViews::PopupViewViews(
base::WeakPtr<AutofillPopupController> controller,
views::Widget* parent_widget)
: PopupBaseView(controller, parent_widget), controller_(controller) {
views::BoxLayout* layout =
SetLayoutManager(std::make_unique<views::BoxLayout>(
views::BoxLayout::Orientation::kVertical));
layout->set_main_axis_alignment(views::BoxLayout::MainAxisAlignment::kStart);
CreateChildViews();
}
PopupViewViews::~PopupViewViews() = default;
void PopupViewViews::GetAccessibleNodeData(ui::AXNodeData* node_data) {
node_data->role = ax::mojom::Role::kListBox;
// If controller_ is valid, then the view is expanded.
if (controller_) {
node_data->AddState(ax::mojom::State::kExpanded);
} else {
node_data->AddState(ax::mojom::State::kCollapsed);
node_data->AddState(ax::mojom::State::kInvisible);
}
node_data->SetNameChecked(
l10n_util::GetStringUTF16(IDS_AUTOFILL_POPUP_ACCESSIBLE_NODE_DATA));
}
void PopupViewViews::Show(
AutoselectFirstSuggestion autoselect_first_suggestion) {
NotifyAccessibilityEvent(ax::mojom::Event::kExpandedChanged, true);
if (DoShow() && autoselect_first_suggestion) {
SetSelectedCell(CellIndex{0u, PopupRowView::CellType::kContent});
}
}
void PopupViewViews::Hide() {
NotifyAccessibilityEvent(ax::mojom::Event::kExpandedChanged, true);
// The controller is no longer valid after it hides us.
controller_ = nullptr;
DoHide();
}
absl::optional<PopupViewViews::CellIndex> PopupViewViews::GetSelectedCell()
const {
// If the suggestions were updated, the cell index may no longer be
// up-to-date, but it cannot simply be reset, because we would lose the
// current selection. Therefore some validity checks need to be performed
// here.
if (!row_with_selected_cell_ ||
!HasPopupRowViewAt(*row_with_selected_cell_)) {
return absl::nullopt;
}
if (absl::optional<PopupRowView::CellType> cell_type =
GetPopupRowViewAt(*row_with_selected_cell_).GetSelectedCell()) {
return CellIndex{*row_with_selected_cell_, *cell_type};
}
return absl::nullopt;
}
void PopupViewViews::SetSelectedCell(absl::optional<CellIndex> cell_index) {
absl::optional<CellIndex> old_index = GetSelectedCell();
if (old_index == cell_index) {
return;
}
if (old_index) {
GetPopupRowViewAt(old_index->first).SetSelectedCell(absl::nullopt);
}
if (cell_index && HasPopupRowViewAt(cell_index->first)) {
row_with_selected_cell_ = cell_index->first;
PopupRowView& new_row = GetPopupRowViewAt(cell_index->first);
new_row.SetSelectedCell(cell_index->second);
new_row.ScrollViewToVisible();
} else {
row_with_selected_cell_ = absl::nullopt;
}
}
bool PopupViewViews::HandleKeyPressEvent(
const content::NativeWebKeyboardEvent& event) {
// If the row can handle the event itself (e.g. switching between cells in the
// same row), we let it.
if (absl::optional<CellIndex> selected_cell = GetSelectedCell()) {
if (GetPopupRowViewAt(selected_cell->first).HandleKeyPressEvent(event)) {
return true;
}
}
const bool kHasShiftModifier =
(event.GetModifiers() & blink::WebInputEvent::kShiftKey);
const bool kHasNonShiftModifier =
(event.GetModifiers() & blink::WebInputEvent::kKeyModifiers &
~blink::WebInputEvent::kShiftKey);
switch (event.windows_key_code) {
case ui::VKEY_UP:
SelectPreviousRow();
return true;
case ui::VKEY_DOWN:
SelectNextRow();
return true;
case ui::VKEY_PRIOR: // Page up.
// Set no line and then select the next line in case the first line is not
// selectable.
SetSelectedCell(absl::nullopt);
SelectNextRow();
return true;
case ui::VKEY_NEXT: // Page down.
SetSelectedCell(absl::nullopt);
SelectPreviousRow();
return true;
case ui::VKEY_RETURN:
return AcceptSelectedCell(/*tab_key_pressed=*/false);
case ui::VKEY_DELETE:
return kHasShiftModifier && RemoveSelectedCell();
case ui::VKEY_TAB:
// We want TAB or Shift+TAB press to cause the selected line to be
// accepted, but still return false so the tab key press propagates and
// change the cursor location.
// We do not want to handle Mod+TAB for other modifiers because this may
// have other purposes (e.g., change the tab).
if (!kHasNonShiftModifier) {
AcceptSelectedCell(/*tab_key_pressed=*/true);
}
return false;
default:
return false;
}
}
void PopupViewViews::SelectPreviousRow() {
DCHECK(!rows_.empty());
absl::optional<CellIndex> old_index = GetSelectedCell();
const PopupRowView::CellType kNewCellType =
old_index ? old_index->second : PopupRowView::CellType::kContent;
// Temporarily use an int to avoid underflows.
int new_row = old_index ? static_cast<int>(old_index->first) - 1 : -1;
while (new_row >= 0 && !HasPopupRowViewAt(new_row)) {
--new_row;
}
if (new_row < 0) {
new_row = static_cast<int>(rows_.size()) - 1;
}
SetSelectedCell(CellIndex{new_row, kNewCellType});
}
void PopupViewViews::SelectNextRow() {
DCHECK(!rows_.empty());
absl::optional<CellIndex> old_index = GetSelectedCell();
const PopupRowView::CellType kNewCellType =
old_index ? old_index->second : PopupRowView::CellType::kContent;
size_t new_row = old_index ? old_index->first + 1u : 0u;
while (new_row < rows_.size() && !HasPopupRowViewAt(new_row)) {
++new_row;
}
if (new_row >= rows_.size()) {
new_row = 0u;
}
SetSelectedCell(CellIndex{new_row, kNewCellType});
}
bool PopupViewViews::AcceptSelectedCell(bool tab_key_pressed) {
absl::optional<CellIndex> index = GetSelectedCell();
if (!controller_ || !index) {
return false;
}
// If the tab key is pressed, only content cells that contain fillable items
// or scanning a credit card may be accepted.
if (tab_key_pressed) {
if (index->second != PopupRowView::CellType::kContent) {
return false;
}
PopupItemId popup_item_id =
controller_->GetSuggestionAt(index->first).popup_item_id;
if (!base::Contains(kItemsTriggeringFieldFilling, popup_item_id) &&
popup_item_id != PopupItemId::kScanCreditCard) {
return false;
}
}
if (base::FeatureList::IsEnabled(
features::kAutofillPopupUseThresholdForKeyboardAndMobileAccept)) {
controller_->AcceptSuggestion(index->first);
} else {
controller_->AcceptSuggestionWithoutThreshold(index->first);
}
return true;
}
bool PopupViewViews::RemoveSelectedCell() {
absl::optional<CellIndex> index = GetSelectedCell();
// Only content cells can be removed.
if (!index || index->second != PopupRowView::CellType::kContent ||
!controller_) {
return false;
}
bool was_autocomplete =
controller_->GetSuggestionAt(index->first).popup_item_id ==
PopupItemId::kAutocompleteEntry;
if (!controller_->RemoveSuggestion(index->first)) {
return false;
}
if (was_autocomplete) {
AutofillMetrics::OnAutocompleteSuggestionDeleted(
AutofillMetrics::AutocompleteSingleEntryRemovalMethod::
kKeyboardShiftDeletePressed);
}
return true;
}
void PopupViewViews::OnSuggestionsChanged() {
CreateChildViews();
DoUpdateBoundsAndRedrawPopup();
}
absl::optional<int32_t> PopupViewViews::GetAxUniqueId() {
return absl::optional<int32_t>(
PopupBaseView::GetViewAccessibility().GetUniqueId());
}
void PopupViewViews::AxAnnounce(const std::u16string& text) {
Browser* browser = chrome::FindLastActive();
if (!browser) {
return;
}
BrowserView* browser_view = BrowserView::GetBrowserViewForBrowser(browser);
if (!browser_view) {
return;
}
browser_view->GetViewAccessibility().AnnounceText(text);
}
void PopupViewViews::OnWidgetVisibilityChanged(views::Widget* widget,
bool visible) {
if (!visible || !controller_) {
return;
}
Browser* browser = GetBrowser();
if (!browser) {
return;
}
// Show the in-product-help promo anchored to this bubble.
// The in-product-help promo is a bubble anchored to this row item to show
// educational messages. The promo bubble should only be shown once in one
// session and has a limit for how many times it can be shown at most in a
// period of time.
browser->window()->MaybeShowFeaturePromo(
feature_engagement::kIPHAutofillVirtualCardSuggestionFeature);
browser->window()->MaybeShowFeaturePromo(
feature_engagement::kIPHAutofillExternalAccountProfileSuggestionFeature);
}
bool PopupViewViews::HasPopupRowViewAt(size_t index) const {
return index < rows_.size() &&
absl::holds_alternative<PopupRowView*>(rows_[index]);
}
void PopupViewViews::CreateChildViews() {
// Null all pointers prior to deleting the children views to avoid temporarily
// dangling pointers that might be picked up by dangle detection builds.
scroll_view_ = nullptr;
body_container_ = nullptr;
rows_.clear();
RemoveAllChildViews();
const std::vector<Suggestion> kSuggestions = controller_->GetSuggestions();
SetBackground(
views::CreateThemedSolidBackground(ui::kColorDropdownBackground));
// `content_view` wraps the full content of the popup and provides vertical
// padding.
raw_ptr<views::BoxLayoutView> content_view =
AddChildView(views::Builder<views::BoxLayoutView>()
.SetOrientation(views::BoxLayout::Orientation::kVertical)
.SetInsideBorderInsets(
gfx::Insets::VH(GetContentsVerticalPadding(), 0))
.Build());
rows_.reserve(kSuggestions.size());
size_t current_line_number = 0u;
// Add the body rows, if there are any.
if (!kSuggestions.empty() && !IsFooterItem(kSuggestions, 0u)) {
// Create a container to wrap the "regular" (non-footer) rows.
std::unique_ptr<views::BoxLayoutView> body_container =
views::Builder<views::BoxLayoutView>()
.SetOrientation(views::BoxLayout::Orientation::kVertical)
.Build();
for (; current_line_number < kSuggestions.size() &&
!IsFooterItem(kSuggestions, current_line_number);
++current_line_number) {
switch (kSuggestions[current_line_number].popup_item_id) {
case PopupItemId::kSeparator:
rows_.push_back(body_container->AddChildView(
std::make_unique<PopupSeparatorView>()));
break;
case PopupItemId::kMixedFormMessage:
case PopupItemId::kInsecureContextPaymentDisabledMessage:
rows_.push_back(
body_container->AddChildView(std::make_unique<PopupWarningView>(
kSuggestions[current_line_number])));
break;
// The default section contains all selectable rows and includes
// autocomplete, address, credit cards and passwords.
default:
PopupRowView* row_view = body_container->AddChildView(
PopupRowView::Create(*this, current_line_number));
rows_.push_back(row_view);
const std::string& feature_for_iph =
kSuggestions[current_line_number].feature_for_iph;
// Set appropriate element ids for IPH targets, it is important to
// set them earlier to make sure the elements are discoverable later
// during popup's visibility change and the promo bubble showing.
if (feature_for_iph ==
feature_engagement::kIPHAutofillVirtualCardSuggestionFeature
.name) {
row_view->SetProperty(views::kElementIdentifierKey,
kAutofillCreditCardSuggestionEntryElementId);
}
if (feature_for_iph ==
feature_engagement::
kIPHAutofillExternalAccountProfileSuggestionFeature.name) {
row_view->SetProperty(views::kElementIdentifierKey,
kAutofillSuggestionElementId);
}
}
}
std::unique_ptr<views::ScrollView> scroll_view =
views::Builder<views::ScrollView>()
.SetBackgroundThemeColorId(ui::kColorDropdownBackground)
.SetHorizontalScrollBarMode(
views::ScrollView::ScrollBarMode::kDisabled)
.SetDrawOverflowIndicator(false)
.ClipHeightTo(0, body_container->GetPreferredSize().height())
.Build();
body_container_ = scroll_view->SetContents(std::move(body_container));
scroll_view_ = content_view->AddChildView(std::move(scroll_view));
content_view->SetFlexForView(scroll_view_.get(), 1);
}
if (current_line_number >= kSuggestions.size()) {
return;
}
// Footer items need to be in their own container because they should not be
// affected by scrolling behavior (they are "sticky" at the bottom) and
// because they have a special background color
std::unique_ptr<views::BoxLayoutView> footer_container =
views::Builder<views::BoxLayoutView>()
.SetOrientation(views::BoxLayout::Orientation::kVertical)
.SetBackground(
views::CreateThemedSolidBackground(ui::kColorDropdownBackground))
.Build();
for (; current_line_number < kSuggestions.size(); ++current_line_number) {
DCHECK(IsFooterItem(kSuggestions, current_line_number));
// The footer can contain either footer views or separator lines.
if (kSuggestions[current_line_number].popup_item_id ==
PopupItemId::kSeparator) {
rows_.push_back(footer_container->AddChildView(
std::make_unique<PopupSeparatorView>()));
} else {
rows_.push_back(footer_container->AddChildView(
PopupRowView::Create(*this, current_line_number)));
}
}
content_view->SetFlexForView(
content_view->AddChildView(std::move(footer_container)), 0);
}
int PopupViewViews::AdjustWidth(int width) const {
if (width >= kAutofillPopupMaxWidth) {
return kAutofillPopupMaxWidth;
}
if (width <= kAutofillPopupMinWidth) {
return kAutofillPopupMinWidth;
}
// The popup size is being determined by the contents, rather than the min/max
// or the element bounds. Round up to a multiple of
// |kAutofillPopupWidthMultiple|.
if (width % kAutofillPopupWidthMultiple) {
width +=
(kAutofillPopupWidthMultiple - (width % kAutofillPopupWidthMultiple));
}
return width;
}
bool PopupViewViews::DoUpdateBoundsAndRedrawPopup() {
gfx::Size preferred_size = CalculatePreferredSize();
gfx::Rect popup_bounds;
const gfx::Rect content_area_bounds = GetContentAreaBounds();
// TODO(crbug.com/1262371) Once popups can render outside the main window on
// Linux, use the screen bounds.
const gfx::Rect top_window_bounds = GetTopWindowBounds();
const gfx::Rect& max_bounds_for_popup =
PopupMayExceedContentAreaBounds(controller_->GetWebContents())
? top_window_bounds
: content_area_bounds;
gfx::Rect element_bounds =
gfx::ToEnclosingRect(controller_->element_bounds());
// If the element exceeds the content area, ensure that the popup is still
// visually attached to the input element.
element_bounds.Intersect(content_area_bounds);
if (element_bounds.IsEmpty()) {
controller_->Hide(PopupHidingReason::kElementOutsideOfContentArea);
return false;
}
// Consider the element is |kElementBorderPadding| pixels larger at the top
// and at the bottom in order to reposition the dropdown, so that it doesn't
// look too close to the element.
element_bounds.Inset(
gfx::Insets::VH(/*vertical=*/-kElementBorderPadding, /*horizontal=*/0));
// At least first and last rows of the popup -- a suggestion and, if present,
// the footer -- should be shown in the bounds of the content area so that the
// user notices the presence of the popup and, in particular, the first
// suggestion.
int min_height = std::numeric_limits<int>::max();
if (body_container_) {
const View::Views& children = body_container_->children();
if (!children.empty()) {
min_height = children.front()->GetPreferredSize().height();
if (children.size() > 1) {
min_height += children.back()->GetPreferredSize().height();
}
}
}
if (!CanShowDropdownHere(min_height, max_bounds_for_popup, element_bounds)) {
controller_->Hide(PopupHidingReason::kInsufficientSpace);
return false;
}
CalculatePopupYAndHeight(preferred_size.height(), max_bounds_for_popup,
element_bounds, &popup_bounds);
// Adjust the width to compensate for a scroll bar, if necessary, and for
// other rules.
int scroll_width = 0;
if (scroll_view_ && preferred_size.height() > popup_bounds.height()) {
preferred_size.set_height(popup_bounds.height());
// Because the preferred size is greater than the bounds available, the
// contents will have to scroll. The scroll bar will steal width from the
// content and smoosh everything together. Instead, add to the width to
// compensate.
scroll_width = scroll_view_->GetScrollBarLayoutWidth();
}
preferred_size.set_width(AdjustWidth(preferred_size.width() + scroll_width));
popup_bounds = GetOptionalPositionAndPlaceArrowOnPopup(
element_bounds, content_area_bounds, preferred_size);
if (BoundsOverlapWithAnyOpenPrompt(popup_bounds,
controller_->GetWebContents())) {
controller_->Hide(PopupHidingReason::kOverlappingWithAnotherPrompt);
return false;
}
// On Windows, due to platform-specific implementation details, the previous
// check isn't reliable, and fails to detect open prompts. Since the most
// critical bubble is the permission bubble, we check for that specifically.
if (BoundsOverlapWithOpenPermissionsPrompt(popup_bounds,
controller_->GetWebContents())) {
controller_->Hide(PopupHidingReason::kOverlappingWithAnotherPrompt);
return false;
}
// The pip surface is given the most preference while rendering. So, the
// autofill popup should not be shown when the picture in picture window
// hides the autofill form behind it.
// For more details on how this can happen, see crbug.com/1358647.
if (BoundsOverlapWithPictureInPictureWindow(popup_bounds)) {
controller_->Hide(
PopupHidingReason::kOverlappingWithPictureInPictureWindow);
return false;
}
SetSize(preferred_size);
popup_bounds.Inset(-GetWidget()->GetRootView()->GetInsets());
GetWidget()->SetBounds(popup_bounds);
UpdateClipPath();
// If `kUiCompositorScrollWithLayers` is enabled, then a ScrollView performs
// scrolling by using layers. These layers are not affected by the clip path
// of the widget. If the corner radius of the popup is larger than the
// vertical padding that separates the widget's top border and the
// ScrollView, this will cause pixel artifacts.
// To avoid these, set a corner radius for the ScrollView's ViewPort if layer
// scrolling is enabled.
const int kPaddingCornerDelta =
GetCornerRadius() - GetContentsVerticalPadding();
if (kPaddingCornerDelta > 0 && scroll_view_ &&
base::FeatureList::IsEnabled(::features::kUiCompositorScrollWithLayers)) {
scroll_view_->SetViewportRoundedCornerRadius(
gfx::RoundedCornersF(kPaddingCornerDelta));
}
SchedulePaint();
return true;
}
base::WeakPtr<AutofillPopupView> PopupViewViews::GetWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
BEGIN_METADATA(PopupViewViews, PopupBaseView)
ADD_PROPERTY_METADATA(absl::optional<PopupViewViews::CellIndex>, SelectedCell)
END_METADATA
// static
base::WeakPtr<AutofillPopupView> AutofillPopupView::Create(
base::WeakPtr<AutofillPopupController> controller) {
#if BUILDFLAG(IS_MAC)
// It's possible for the container_view to not be in a window. In that case,
// cancel the popup since we can't fully set it up.
if (!platform_util::GetTopLevel(controller->container_view())) {
return nullptr;
}
#endif
views::Widget* observing_widget =
views::Widget::GetTopLevelWidgetForNativeView(
controller->container_view());
#if !BUILDFLAG(IS_MAC)
// If the top level widget can't be found, cancel the popup since we can't
// fully set it up. On Mac Cocoa browser, |observing_widget| is null
// because the parent is not a views::Widget.
if (!observing_widget) {
return nullptr;
}
#endif
return (new PopupViewViews(controller, observing_widget))->GetWeakPtr();
}
} // namespace autofill