| // 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 "chrome/browser/ui/views/autofill/popup/popup_row_view.h" |
| |
| #include <memory> |
| #include <optional> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "base/check.h" |
| #include "base/check_op.h" |
| #include "base/feature_list.h" |
| #include "base/functional/bind.h" |
| #include "base/functional/callback_forward.h" |
| #include "base/i18n/rtl.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/memory/weak_ptr.h" |
| #include "base/metrics/histogram_functions.h" |
| #include "base/strings/string_util.h" |
| #include "base/time/time.h" |
| #include "chrome/browser/ui/autofill/autofill_popup_controller.h" |
| #include "chrome/browser/ui/views/autofill/popup/popup_cell_utils.h" |
| #include "chrome/browser/ui/views/autofill/popup/popup_row_content_view.h" |
| #include "chrome/browser/ui/views/autofill/popup/popup_row_with_button_view.h" |
| #include "chrome/browser/ui/views/autofill/popup/popup_view_utils.h" |
| #include "chrome/browser/ui/views/autofill/popup/popup_view_views.h" |
| #include "chrome/browser/ui/views/chrome_layout_provider.h" |
| #include "components/autofill/core/browser/filling/filling_product.h" |
| #include "components/autofill/core/browser/metrics/autofill_metrics.h" |
| #include "components/autofill/core/browser/suggestions/suggestion.h" |
| #include "components/autofill/core/browser/suggestions/suggestion_type.h" |
| #include "components/autofill/core/common/autofill_features.h" |
| #include "components/strings/grit/components_strings.h" |
| #include "third_party/blink/public/common/input/web_input_event.h" |
| #include "ui/accessibility/ax_enums.mojom.h" |
| #include "ui/accessibility/ax_node_data.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/base_type_conversion.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/color/color_id.h" |
| #include "ui/events/event_handler.h" |
| #include "ui/events/event_utils.h" |
| #include "ui/events/keycodes/keyboard_codes.h" |
| #include "ui/events/types/event_type.h" |
| #include "ui/gfx/geometry/insets.h" |
| #include "ui/gfx/geometry/outsets_f.h" |
| #include "ui/gfx/geometry/rect_f.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/background.h" |
| #include "ui/views/controls/image_view.h" |
| #include "ui/views/layout/box_layout.h" |
| #include "ui/views/layout/layout_provider.h" |
| #include "ui/views/view.h" |
| #include "ui/views/view_class_properties.h" |
| |
| namespace autofill { |
| |
| namespace { |
| |
| // Utility event handler for mouse enter/exit and tap events. |
| class EnterExitHandler : public ui::EventHandler { |
| public: |
| EnterExitHandler(base::RepeatingClosure enter_callback, |
| base::RepeatingClosure exit_callback); |
| EnterExitHandler(const EnterExitHandler&) = delete; |
| EnterExitHandler& operator=(const EnterExitHandler&) = delete; |
| ~EnterExitHandler() override; |
| |
| void OnEvent(ui::Event* event) override; |
| |
| private: |
| base::RepeatingClosure enter_callback_; |
| base::RepeatingClosure exit_callback_; |
| }; |
| |
| constexpr int kExpandChildSuggestionsViewWidth = 24; |
| constexpr int kExpandChildSuggestionsIconWidth = 16; |
| constexpr int kExpandChildSuggestionsViewHorizontalPadding = |
| (kExpandChildSuggestionsViewWidth - kExpandChildSuggestionsIconWidth) / 2; |
| |
| // The suggestion is considered acceptable if the following is true: |
| // row view visible area >= total area * kAcceptingGuardVisibleAreaPortion. |
| // See how it is used in `PopupRowView::OnVisibleBoundsChanged()` for details. |
| constexpr float kAcceptingGuardVisibleAreaPortion = 0.5; |
| |
| // Computes the position and set size of the suggestion at `suggestion_index` in |
| // `controller`'s suggestions ignoring `SuggestionType::kSeparator`s. |
| // Returns a pair of numbers: <position, size>. The position value is 1-base. |
| std::pair<int, int> ComputePositionInSet( |
| base::WeakPtr<AutofillPopupController> controller, |
| int suggestion_index) { |
| CHECK(controller); |
| |
| int set_size = 0; |
| int set_index = suggestion_index + 1; |
| for (int i = 0; i < controller->GetLineCount(); ++i) { |
| if (controller->GetSuggestionAt(i).type != SuggestionType::kSeparator) { |
| ++set_size; |
| continue; |
| } |
| if (i < suggestion_index) { |
| --set_index; |
| } |
| } |
| return {set_index, set_size}; |
| } |
| |
| std::u16string GetSuggestionA11yString(const Suggestion& suggestion, |
| bool add_call_to_action_if_expandable) { |
| std::vector<std::u16string> text( |
| {popup_cell_utils::GetVoiceOverStringFromSuggestion(suggestion)}); |
| |
| if (!suggestion.children.empty()) { |
| CHECK(IsExpandableSuggestionType(suggestion.type)); |
| |
| if (suggestion.type == SuggestionType::kAddressEntry && |
| add_call_to_action_if_expandable) { |
| text.push_back(l10n_util::GetStringUTF16( |
| IDS_AUTOFILL_EXPANDABLE_SUGGESTION_FILL_ADDRESS_A11Y_ADDON)); |
| } |
| |
| std::u16string shortcut = l10n_util::GetStringUTF16( |
| base::i18n::IsRTL() |
| ? IDS_AUTOFILL_EXPANDABLE_SUGGESTION_EXPAND_SHORTCUT_RTL |
| : IDS_AUTOFILL_EXPANDABLE_SUGGESTION_EXPAND_SHORTCUT); |
| |
| text.push_back(l10n_util::GetStringFUTF16( |
| IDS_AUTOFILL_EXPANDABLE_SUGGESTION_SUBMENU_HINT, shortcut)); |
| } |
| |
| return base::JoinString(text, u". "); |
| } |
| |
| } // namespace |
| |
| EnterExitHandler::EnterExitHandler(base::RepeatingClosure enter_callback, |
| base::RepeatingClosure exit_callback) |
| : enter_callback_(std::move(enter_callback)), |
| exit_callback_(std::move(exit_callback)) {} |
| |
| EnterExitHandler::~EnterExitHandler() = default; |
| void EnterExitHandler::OnEvent(ui::Event* event) { |
| switch (event->type()) { |
| case ui::EventType::kMouseEntered: |
| enter_callback_.Run(); |
| break; |
| case ui::EventType::kMouseExited: |
| exit_callback_.Run(); |
| break; |
| case ui::EventType::kGestureTapDown: |
| enter_callback_.Run(); |
| break; |
| case ui::EventType::kGestureTapCancel: |
| case ui::EventType::kGestureEnd: |
| exit_callback_.Run(); |
| break; |
| default: |
| break; |
| } |
| } |
| |
| // static |
| int PopupRowView::GetHorizontalMargin() { |
| return ChromeLayoutProvider::Get()->GetDistanceMetric( |
| DISTANCE_CONTENT_LIST_VERTICAL_SINGLE); |
| } |
| |
| PopupRowView::PopupRowView( |
| AccessibilitySelectionDelegate& a11y_selection_delegate, |
| SelectionDelegate& selection_delegate, |
| base::WeakPtr<AutofillPopupController> controller, |
| int line_number, |
| std::unique_ptr<PopupRowContentView> content_view) |
| : a11y_selection_delegate_(a11y_selection_delegate), |
| selection_delegate_(selection_delegate), |
| controller_(controller), |
| line_number_(line_number), |
| should_ignore_mouse_observed_outside_item_bounds_check_( |
| controller && |
| controller->ShouldIgnoreMouseObservedOutsideItemBoundsCheck()), |
| suggestion_is_acceptable_( |
| controller && line_number < controller->GetLineCount() && |
| controller->GetSuggestionAt(line_number).IsAcceptable()) { |
| CHECK(content_view); |
| CHECK(controller_); |
| CHECK_LT(line_number_, controller_->GetLineCount()); |
| |
| SetFocusBehavior(FocusBehavior::ALWAYS); |
| SetNotifyEnterExitOnChild(true); |
| SetProperty(views::kMarginsKey, gfx::Insets::VH(0, GetHorizontalMargin())); |
| SetBackground(views::CreateSolidBackground(ui::kColorDropdownBackground)); |
| |
| views::BoxLayout* layout = |
| SetLayoutManager(std::make_unique<views::BoxLayout>()); |
| |
| auto set_exit_enter_callbacks = [&](CellType type, views::View& cell) { |
| auto handler = std::make_unique<EnterExitHandler>( |
| /*enter_callback=*/base::BindRepeating( |
| [](PopupRowView* view, CellType type) { |
| // `OnMouseEntered()` does not imply that the mouse had been |
| // outside of the item's bounds before: `OnMouseEntered()` fires |
| // if the mouse moves just a little bit on the item. If the |
| // trigger source is not manual fallback we don't want to show a |
| // preview in such a case. In this case of manual fallback we do |
| // not care since the user has made a specific choice of opening |
| // the autofill popup. |
| bool can_select_suggestion = |
| view->mouse_observed_outside_item_bounds_ || |
| view->should_ignore_mouse_observed_outside_item_bounds_check_; |
| if (can_select_suggestion) { |
| view->OnCellSelected(type, PopupCellSelectionSource::kMouse); |
| } |
| }, |
| this, type), |
| /*exit_callback=*/base::BindRepeating( |
| &PopupRowView::OnCellSelected, base::Unretained(this), std::nullopt, |
| PopupCellSelectionSource::kMouse)); |
| // Setting this handler on the cell view removes its original event handler |
| // (i.e. overridden methods like OnMouse*). Make sure the root view doesn't |
| // handle events itself and consider using `ui::ScopedTargetHandler` if it |
| // actually needs them. |
| cell.SetTargetHandler(handler.get()); |
| return handler; |
| }; |
| |
| const Suggestion& suggestion = controller_->GetSuggestionAt(line_number); |
| |
| content_view_ = AddChildView(std::move(content_view)); |
| content_view_->SetFocusBehavior(FocusBehavior::ALWAYS); |
| content_view_observer_.Observe(content_view_); |
| content_view_->GetViewAccessibility().SetRole( |
| ax::mojom::Role::kListBoxOption); |
| content_view_->GetViewAccessibility().SetName( |
| GetSuggestionA11yString(suggestion, |
| /*add_call_to_action_if_expandable=*/ |
| suggestion.IsAcceptable()), |
| ax::mojom::NameFrom::kAttribute); |
| auto [position, set_size] = ComputePositionInSet(controller_, line_number); |
| content_view_->GetViewAccessibility().SetPosInSet(position); |
| content_view_->GetViewAccessibility().SetSetSize(set_size); |
| content_view_->GetViewAccessibility().SetIsSelected(false); |
| |
| GetViewAccessibility().SetRole(ax::mojom::Role::kListBoxOption); |
| GetViewAccessibility().SetName( |
| GetSuggestionA11yString(suggestion, |
| /*add_call_to_action_if_expandable=*/false)); |
| GetViewAccessibility().SetPosInSet(position); |
| GetViewAccessibility().SetSetSize(set_size); |
| |
| content_event_handler_ = |
| set_exit_enter_callbacks(CellType::kContent, *content_view_); |
| layout->SetFlexForView(content_view_.get(), 1); |
| |
| if (!suggestion.children.empty()) { |
| expand_child_suggestions_view_ = |
| AddChildView(std::make_unique<views::View>()); |
| expand_child_suggestions_view_->SetNotifyEnterExitOnChild(true); |
| expand_child_suggestions_view_->SetLayoutManager( |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, |
| gfx::Insets(kExpandChildSuggestionsViewHorizontalPadding))); |
| expand_child_suggestions_view_->AddChildView( |
| std::make_unique<views::ImageView>( |
| popup_cell_utils::ImageModelFromVectorIcon( |
| popup_cell_utils::GetExpandableMenuIcon(suggestion.type), |
| kExpandChildSuggestionsIconWidth))); |
| expand_child_suggestions_view_observer_.Observe( |
| expand_child_suggestions_view_); |
| control_event_handler_ = set_exit_enter_callbacks( |
| CellType::kControl, *expand_child_suggestions_view_); |
| layout->SetFlexForView(expand_child_suggestions_view_.get(), 0); |
| } |
| } |
| |
| PopupRowView::~PopupRowView() = default; |
| |
| bool PopupRowView::OnMouseDragged(const ui::MouseEvent& event) { |
| // Return `true` to be informed about subsequent `OnMouseReleased` events. |
| return true; |
| } |
| |
| bool PopupRowView::OnMousePressed(const ui::MouseEvent& event) { |
| // Return `true` to be informed about subsequent `OnMouseReleased` events. |
| return true; |
| } |
| |
| void PopupRowView::OnMouseExited(const ui::MouseEvent& event) { |
| // `OnMouseExited()` does not imply that the mouse has left the item's screen |
| // bounds: `OnMouseExited()` fires (on Windows, at least) when another popup |
| // overlays this item and the mouse is above the new popup |
| // (crbug.com/1287364). |
| mouse_observed_outside_item_bounds_ |= !IsMouseHovered(); |
| } |
| |
| void PopupRowView::OnMouseReleased(const ui::MouseEvent& event) { |
| // For trigger sources different from manual fallback we ignore mouse clicks |
| // unless the user made the explicit choice to select the current item. In |
| // the manual fallback case the user has made an explicit choice of opening |
| // the popup and so will not select an address by accident. |
| if (!mouse_observed_outside_item_bounds_ && |
| !should_ignore_mouse_observed_outside_item_bounds_check_) { |
| return; |
| } |
| |
| if (event.IsOnlyLeftMouseButton() && |
| content_view_->HitTestPoint(event.location()) && controller_ && |
| IsViewVisibleEnough()) { |
| controller_->AcceptSuggestion( |
| line_number_, AutofillMetrics::SuggestionAcceptedMethod::kMouse); |
| } |
| } |
| |
| void PopupRowView::OnGestureEvent(ui::GestureEvent* event) { |
| switch (event->type()) { |
| case ui::EventType::kGestureTap: |
| if (content_view_->HitTestPoint(event->location()) && controller_ && |
| IsViewVisibleEnough()) { |
| controller_->AcceptSuggestion( |
| line_number_, AutofillMetrics::SuggestionAcceptedMethod::kTap); |
| } |
| break; |
| default: |
| return; |
| } |
| } |
| |
| void PopupRowView::OnPaint(gfx::Canvas* canvas) { |
| views::View::OnPaint(canvas); |
| mouse_observed_outside_item_bounds_ |= !IsMouseHovered(); |
| } |
| |
| bool PopupRowView::GetNeedsNotificationWhenVisibleBoundsChange() const { |
| return base::FeatureList::IsEnabled( |
| features::kAutofillPopupDontAcceptNonVisibleEnoughSuggestion); |
| } |
| |
| void PopupRowView::OnVisibleBoundsChanged() { |
| if (GetVisibleBounds().size().GetArea() >= |
| size().GetArea() * kAcceptingGuardVisibleAreaPortion) { |
| barrier_for_accepting_ = NextIdleBarrier::CreateNextIdleBarrierWithDelay( |
| AutofillSuggestionController::kIgnoreEarlyClicksOnSuggestionsDuration); |
| } else { |
| barrier_for_accepting_.reset(); |
| } |
| } |
| |
| void PopupRowView::OnViewFocused(views::View* view) { |
| CHECK(view == content_view_ || view == expand_child_suggestions_view_); |
| |
| CellType type = |
| view == content_view_ ? CellType::kContent : CellType::kControl; |
| // Focus may come not only from the keyboard (e.g. from devices used for |
| // a11y), but for selection purposes these non-mouse sources are similar |
| // enough to treat them equally as a keyboard. |
| OnCellSelected(type, PopupCellSelectionSource::kKeyboard); |
| } |
| |
| void PopupRowView::SetSelectedCell(std::optional<CellType> new_cell) { |
| if (!controller_) { |
| return; |
| } |
| |
| if (new_cell == selected_cell_) { |
| return; |
| } |
| |
| // If the previous cell was content, set it as unselected. |
| if (selected_cell_ == CellType::kContent) { |
| content_view_->UpdateStyle(/*selected=*/false); |
| content_view_->GetViewAccessibility().SetIsSelected(false); |
| controller_->UnselectSuggestion(); |
| } |
| |
| if ((new_cell == CellType::kControl && expand_child_suggestions_view_) || |
| (new_cell == CellType::kContent && !suggestion_is_acceptable_)) { |
| // TODO(crbug.com/370695550): `SetIsSelected()` must go after |
| // `NotifyAXSelection()` as the latter calls `SetPopupFocusOverride()` that |
| // is required for a11y focus working on a non-activatable popup. Consider |
| // moving `SetIsSelected()` into `NotifyAXSelection()` (and rename it) to |
| // hide this API complexity from clients. |
| GetA11ySelectionDelegate().NotifyAXSelection(*this); |
| GetViewAccessibility().SetIsSelected(true); |
| NotifyAccessibilityEventDeprecated( |
| ax::mojom::Event::kSelectedChildrenChanged, true); |
| selected_cell_ = new_cell; |
| } else if (new_cell == CellType::kContent) { |
| controller_->SelectSuggestion(line_number_); |
| content_view_->UpdateStyle(/*selected=*/true); |
| GetA11ySelectionDelegate().NotifyAXSelection(*content_view_); |
| content_view_->GetViewAccessibility().SetIsSelected(true); |
| NotifyAccessibilityEventDeprecated( |
| ax::mojom::Event::kSelectedChildrenChanged, true); |
| selected_cell_ = new_cell; |
| } else { |
| // Set the selected cell to none in case an invalid choice was made (e.g. |
| // selecting a control cell when none exists) or the cell was reset |
| // explicitly with `std::nullopt`. |
| selected_cell_ = std::nullopt; |
| |
| GetViewAccessibility().SetIsSelected(false); |
| content_view_->GetViewAccessibility().SetIsSelected(false); |
| } |
| |
| UpdateBackground(); |
| } |
| |
| void PopupRowView::SetChildSuggestionsDisplayed( |
| bool child_suggestions_displayed) { |
| child_suggestions_displayed_ = child_suggestions_displayed; |
| |
| UpdateBackground(); |
| } |
| |
| gfx::RectF PopupRowView::GetControlCellBounds() const { |
| // The view is expected to be present. |
| gfx::RectF bounds = |
| gfx::RectF(expand_child_suggestions_view_->GetBoundsInScreen()); |
| |
| // Depending on the RTL expand the bounds on the outer side only, so that |
| // the inner sides don't have gaps which may cause unnecessary mouse events |
| // on the parent in case of overlapping by its sub-popup. |
| gfx::OutsetsF extension = |
| base::i18n::IsRTL() |
| ? gfx::OutsetsF::TLBR(0, /*left=*/GetHorizontalMargin(), 0, 0) |
| : gfx::OutsetsF::TLBR(0, 0, 0, /*right=*/GetHorizontalMargin()); |
| bounds.Outset(extension); |
| |
| return bounds; |
| } |
| |
| bool PopupRowView::HandleKeyPressEvent( |
| const input::NativeWebKeyboardEvent& event) { |
| // Some cells may want to define their own behavior. |
| CHECK(GetSelectedCell()); |
| |
| switch (event.windows_key_code) { |
| case ui::VKEY_RETURN: { |
| const bool kHasKeyModifierPressed = |
| event.GetModifiers() & blink::WebInputEvent::kKeyModifiers; |
| if (*GetSelectedCell() == CellType::kContent && controller_ && |
| !kHasKeyModifierPressed && IsViewVisibleEnough()) { |
| controller_->AcceptSuggestion( |
| line_number_, AutofillMetrics::SuggestionAcceptedMethod::kKeyboard); |
| return true; |
| } |
| return false; |
| } |
| default: |
| return false; |
| } |
| } |
| |
| bool PopupRowView::IsSelectable() const { |
| return controller_ && line_number_ < controller_->GetLineCount() && |
| !controller_->GetSuggestionAt(line_number_).HasDeactivatedStyle(); |
| } |
| |
| void PopupRowView::OnCellSelected(std::optional<CellType> type, |
| PopupCellSelectionSource source) { |
| selection_delegate_->SetSelectedCell( |
| type ? std::make_optional(PopupViewViews::CellIndex{line_number_, *type}) |
| : std::nullopt, |
| source); |
| } |
| |
| void PopupRowView::UpdateBackground() { |
| const bool is_highlighted = [&]() { |
| // The whole row is highlighted when the subpopup is open, or ... |
| if (child_suggestions_displayed_) { |
| return true; |
| } |
| // the expanding control view is being hovered, or ... |
| if (selected_cell_ == CellType::kControl) { |
| return true; |
| } |
| // the suggestion is not acceptable and either the control or content part |
| // is being hovered. |
| return !suggestion_is_acceptable_ && selected_cell_; |
| }(); |
| SetBackground(views::CreateRoundedRectBackground( |
| is_highlighted ? ui::kColorDropdownBackgroundSelected |
| : ui::kColorDropdownBackground, |
| ChromeLayoutProvider::Get()->GetCornerRadiusMetric( |
| views::Emphasis::kMedium))); |
| } |
| |
| bool PopupRowView::IsViewVisibleEnough() const { |
| if (controller_ && |
| !controller_->IsViewVisibilityAcceptingThresholdEnabled()) { |
| return true; |
| } |
| |
| if (!base::FeatureList::IsEnabled( |
| features::kAutofillPopupDontAcceptNonVisibleEnoughSuggestion)) { |
| return true; |
| } |
| |
| bool visible_enough = |
| barrier_for_accepting_ && barrier_for_accepting_->value(); |
| |
| base::UmaHistogramBoolean( |
| "Autofill.AcceptedSuggestionDesktopRowViewVisibleEnough", visible_enough); |
| |
| return visible_enough; |
| } |
| |
| BEGIN_METADATA(PopupRowView) |
| ADD_PROPERTY_METADATA(std::optional<PopupRowView::CellType>, SelectedCell) |
| END_METADATA |
| |
| } // namespace autofill |
| |
| DEFINE_ENUM_CONVERTERS(autofill::PopupRowView::CellType, |
| {autofill::PopupRowView::CellType::kContent, |
| u"kContent"}, |
| {autofill::PopupRowView::CellType::kControl, |
| u"kControl"}) |