blob: ff39192f103f66a336583e0ed6b2ef69152d9634 [file] [log] [blame]
// Copyright 2014 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_base_view.h"
#include <algorithm>
#include <memory>
#include <string_view>
#include <utility>
#include "base/dcheck_is_on.h"
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/i18n/rtl.h"
#include "base/location.h"
#include "chrome/browser/platform_util.h"
#include "chrome/browser/themes/theme_service.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/views/autofill/popup/custom_cursor_suppressor.h"
#include "chrome/browser/ui/views/autofill/popup/popup_view_utils.h"
#include "chrome/browser/ui/views/chrome_layout_provider.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "chrome/browser/ui/views/frame/contents_web_view.h"
#include "components/autofill/core/common/autofill_features.h"
#include "components/password_manager/core/browser/features/password_features.h"
#include "components/strings/grit/components_strings.h"
#include "content/public/browser/web_contents.h"
#include "third_party/skia/include/core/SkPath.h"
#include "third_party/skia/include/core/SkRRect.h"
#include "ui/accessibility/ax_enums.mojom.h"
#include "ui/accessibility/platform/ax_platform_node.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/color/color_id.h"
#include "ui/color/color_provider.h"
#include "ui/display/display.h"
#include "ui/display/screen.h"
#include "ui/events/base_event_utils.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/geometry/skia_conversions.h"
#include "ui/gfx/scrollbar_size.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/border.h"
#include "ui/views/bubble/bubble_border.h"
#include "ui/views/controls/menu/menu_config.h"
#include "ui/views/focus/focus_manager.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/widget/widget.h"
#if DCHECK_IS_ON()
#include "base/containers/fixed_flat_set.h"
#endif
namespace autofill {
namespace {
// The maximum size (in DIPs) of custom cursors that are permitted while the
// popup is shown. The size is limited to avoid custom cursors that cover most
// of the popup.
constexpr int kMaximumAllowedCustomCursorDimension = 24;
// The maximum number of pixels the suggestions dialog is shifted towards the
// center the focused field.
constexpr int kMaximumPixelsToMoveSuggestionToCenter = 120;
// The maximum width percentage the suggestion dialog is shifted towards the
// center of the focused field.
constexpr int kMaximumWidthPercentageToMoveTheSuggestionToCenter = 50;
// The max number of pixels the popup is allowed to be rendered above the top
// of the `WebContents`. Limiting overflow prevents the popup content from
// covering important browser elements (e.g., the address bar).
constexpr int kMaxPopupWebContentsTopYOverflow = 8;
// Creates a border for a popup.
std::unique_ptr<views::Border> CreateBorder() {
auto border = std::make_unique<views::BubbleBorder>(
views::BubbleBorder::NONE, views::BubbleBorder::STANDARD_SHADOW);
border->SetColor(ui::kColorDropdownBackground);
border->set_rounded_corners(
gfx::RoundedCornersF(PopupBaseView::GetCornerRadius()));
border->set_md_shadow_elevation(
ChromeLayoutProvider::Get()->GetShadowElevationMetric(
base::FeatureList::IsEnabled(features::kAutofillMoreProminentPopup)
? views::Emphasis::kMaximum
: views::Emphasis::kMedium));
return border;
}
} // namespace
// static
int PopupBaseView::GetCornerRadius() {
return ChromeLayoutProvider::Get()->GetCornerRadiusMetric(
views::Emphasis::kHigh);
}
// static
int PopupBaseView::ArrowHorizontalMargin() {
// The horizontal margin should match the offset of the bubble arrow (if
// that arrow happens to be shown on the top).
return views::BubbleBorder::kVisibleArrowBuffer;
}
// The widget that the PopupBaseView will be attached to.
class PopupBaseView::Widget : public views::Widget {
public:
// Takes ownership of `autofill_popup_base_view` and uses it as the delegate
// of a new Widget. `parent_native_view` is the intended parent view of the
// new Widget.
explicit Widget(PopupBaseView* autofill_popup_base_view,
gfx::NativeView parent_native_view,
views::Widget::InitParams::Activatable activatable) {
views::Widget::InitParams params(
views::Widget::InitParams::NATIVE_WIDGET_OWNS_WIDGET,
views::Widget::InitParams::TYPE_POPUP);
params.delegate = autofill_popup_base_view;
params.parent = parent_native_view;
// Ensure the popup border is not painted on an opaque background.
params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent;
params.shadow_type = views::Widget::InitParams::ShadowType::kNone;
params.activatable = activatable;
// `kSecuritySurface` makes the popup to display on top of all other windows
// (including system ones, but the support among different OS, versions and
// setups is not consistent). This is not required for regular autofill
// popup use, but it makes certain attacks (those based on the popup being
// obscured) less practical.
if (base::FeatureList::IsEnabled(
features::kAutofillPopupZOrderSecuritySurface)) {
params.z_order = ui::ZOrderLevel::kSecuritySurface;
}
Init(std::move(params));
AddObserver(popup_base_view());
// No animation for popup appearance (too distracting).
SetVisibilityAnimationTransition(views::Widget::ANIMATE_HIDE);
}
PopupBaseView* popup_base_view() const {
// This cast is always safe since we pass the base view as a delegate.
return static_cast<PopupBaseView*>(widget_delegate());
}
// views::Widget:
const ui::ThemeProvider* GetThemeProvider() const override {
if (!popup_base_view() || popup_base_view()->GetBrowser()) {
return nullptr;
}
return &ThemeService::GetThemeProviderForProfile(
popup_base_view()->GetBrowser()->profile());
}
views::Widget* GetPrimaryWindowWidget() override {
if (!popup_base_view() || !popup_base_view()->GetBrowser()) {
return nullptr;
}
BrowserView* browser_view =
BrowserView::GetBrowserViewForBrowser(popup_base_view()->GetBrowser());
if (!browser_view) {
return nullptr;
}
return browser_view->GetWidget()->GetPrimaryWindowWidget();
}
void OnMouseEvent(ui::MouseEvent* event) override {
views::View* parent_content_view =
parent() ? parent()->GetContentsView() : nullptr;
if (!parent_content_view) {
views::Widget::OnMouseEvent(event);
return;
}
// Suppress the exit event on MacOS and Windows generated when the sub-popup
// initially opens. We assume that it is the sub-popup that hovers
// the parent by its semi-transparent shadow part. But in theory it could be
// another window, which is not a problem because the popup closes on focus
// loss anyway. The exit event will be synthesized by the sub-popup later
// (find the trick that does this below).
if (event->type() == ui::EventType::kMouseExited &&
GetContentsView()->IsMouseHovered()) {
return;
}
// Retrigger mouse moves on the parent to make selection/highlighting work
// properly and thus provide more intuitive UX when the child's transparent
// parts (e.g. shadow) overlap the parent (assuming that the child contents
// view is not overlapped).
if (event->type() == ui::EventType::kMouseMoved &&
!GetContentsView()->IsMouseHovered() &&
parent_content_view->IsMouseHovered()) {
parent()->SynthesizeMouseMoveEvent();
// Save the synthesized event position to use it for the exit event
// later.
last_synthesized_parent_mouse_move_position_ =
display::Screen::Get()->GetCursorScreenPoint();
} else if (!parent_content_view->IsMouseHovered() &&
last_synthesized_parent_mouse_move_position_.has_value()) {
// Generate the exit event after a set of move events as there is no one
// handling this case (when the mouse gets outside of the parent
// widget), which is important for the selection/highlighting state
// consistency.
const gfx::Point location = View::ConvertPointFromScreen(
parent()->GetRootView(),
last_synthesized_parent_mouse_move_position_.value());
ui::MouseEvent mouse_event(ui::EventType::kMouseExited, location,
location, ui::EventTimeForNow(),
ui::EF_IS_SYNTHESIZED,
/*changed_button_flags=*/0);
parent()->OnMouseEvent(&mouse_event);
last_synthesized_parent_mouse_move_position_.reset();
}
views::Widget::OnMouseEvent(event);
}
private:
std::optional<gfx::Point> last_synthesized_parent_mouse_move_position_;
};
PopupBaseView::PopupBaseView(
base::WeakPtr<AutofillPopupViewDelegate> delegate,
views::Widget* parent_widget,
views::Widget::InitParams::Activatable new_widget_activatable,
bool show_arrow_pointer)
: delegate_(delegate),
parent_widget_(parent_widget),
new_widget_activatable_(new_widget_activatable),
show_arrow_pointer_(show_arrow_pointer) {
// TODO(aleventhal) The correct role spec-wise to use here is kMenu, however
// as of NVDA 2018.2.1, firing a menu event with kMenu breaks left/right
// arrow editing feedback in text field. If NVDA addresses this we should
// consider returning to using kMenu, so that users are notified that a
// menu popup has been shown.
GetViewAccessibility().SetRole(ax::mojom::Role::kPane);
GetViewAccessibility().SetName(
l10n_util::GetStringUTF16(IDS_AUTOFILL_POPUP_ACCESSIBLE_NODE_DATA));
}
PopupBaseView::~PopupBaseView() {
if (delegate_) {
delegate_->ViewDestroyed();
}
RemoveWidgetObservers();
CHECK(!IsInObserverList());
}
Browser* PopupBaseView::GetBrowser() {
if (content::WebContents* web_contents = GetWebContents()) {
return chrome::FindBrowserWithTab(web_contents);
}
return nullptr;
}
bool PopupBaseView::DoShow() {
const bool initialize_widget = !GetWidget();
if (initialize_widget) {
// On Mac Cocoa browser, |parent_widget_| is null (the parent is not a
// views::Widget).
// TODO(crbug.com/41379554): Remove |parent_widget_|.
if (parent_widget_) {
parent_widget_->AddObserver(this);
}
// The widget is destroyed by the corresponding NativeWidget, so we don't
// have to worry about deletion.
new PopupBaseView::Widget(this, /*parent_native_view=*/
parent_widget_ ? parent_widget_->GetNativeView()
: delegate_->container_view(),
new_widget_activatable_);
}
GetWidget()->GetRootView()->SetBorder(CreateBorder());
bool enough_height = DoUpdateBoundsAndRedrawPopup();
// If there is insufficient height, DoUpdateBoundsAndRedrawPopup() hides and
// thus deletes |this|. Hence, there is nothing else to do.
if (!enough_height) {
return false;
}
if (GetWebContents()) {
custom_cursor_suppressor_.Start(
/*max_dimension_dips=*/kMaximumAllowedCustomCursorDimension + 1);
} else {
// `delegate_` is already gone and `WebContents` is destroying itself.
return false;
}
GetWidget()->Show();
// Showing the widget can change native focus (which would result in an
// immediate hiding of the popup). Only start observing after shown.
if (initialize_widget) {
CHECK(!focus_observation_.IsObserving());
focus_observation_.Observe(views::NativeViewFocusManager::GetInstance());
}
return true;
}
void PopupBaseView::DoHide() {
if (is_ax_menu_start_event_fired_) {
// Fire menu end event.
// The menu start event is delayed until the user
// navigates into the menu, otherwise some screen readers will ignore
// any focus events outside of the menu, including a focus event on
// the form control itself.
NotifyAccessibilityEventDeprecated(ax::mojom::Event::kMenuPopupEnd, true);
NotifyAccessibilityEventDeprecated(ax::mojom::Event::kMenuEnd, true);
GetViewAccessibility().EndPopupFocusOverride();
// Also fire an accessible focus event on what currently has focus,
// typically the widget associated with this popup.
if (parent_widget_) {
if (views::FocusManager* focus_manager =
parent_widget_->GetFocusManager()) {
if (View* focused_view = focus_manager->GetFocusedView()) {
focused_view->GetViewAccessibility().FireFocusAfterMenuClose();
}
}
}
}
// The controller is no longer valid after it hides us.
delegate_ = nullptr;
RemoveWidgetObservers();
if (GetWidget()) {
// Don't call CloseNow() because some of the functions higher up the stack
// assume the the widget is still valid after this point.
// http://crbug.com/229224
// NOTE: This deletes |this|.
GetWidget()->Close();
} else {
delete this;
}
}
void PopupBaseView::NotifyAXSelection(views::View& selected_view) {
if (!is_ax_menu_start_event_fired_) {
// Fire the menu start event once, right before the first item is selected.
// By firing these and the matching kMenuEnd events, we are telling screen
// readers that the focus is only changing temporarily, and the screen
// reader will restore the focus back to the appropriate textfield when the
// menu closes.
NotifyAccessibilityEventDeprecated(ax::mojom::Event::kMenuStart, true);
NotifyAccessibilityEventDeprecated(ax::mojom::Event::kMenuPopupStart, true);
is_ax_menu_start_event_fired_ = true;
}
selected_view.GetViewAccessibility().SetPopupFocusOverride();
#if DCHECK_IS_ON()
constexpr auto kDerivedClasses = base::MakeFixedFlatSet<std::string_view>(
{"PopupSuggestionView", "PopupPasswordSuggestionView", "PopupFooterView",
"PopupSeparatorView", "PopupWarningView", "PopupBaseView",
"PasswordGenerationPopupViewViews::GeneratedPasswordBox", "PopupRowView",
"PopupRowWithButtonView", "PopupRowContentView", "MdTextButton"});
DCHECK(kDerivedClasses.contains(selected_view.GetClassName()))
<< "If you add a new derived class from AutofillPopupRowView, add it "
"here and to onSelection(evt) in "
"chrome/browser/resources/chromeos/accessibility/chromevox/mv2/"
"background/"
"event/desktop_automation_handler.js to ensure that ChromeVox "
"announces the item when selected. Missing class: "
<< selected_view.GetClassName();
#endif
}
void PopupBaseView::OnWidgetBoundsChanged(views::Widget* widget,
const gfx::Rect& new_bounds) {
CHECK(widget == parent_widget_ || widget == GetWidget());
if (widget != parent_widget_) {
return;
}
HideController(SuggestionHidingReason::kWidgetChanged);
}
void PopupBaseView::OnWidgetDestroying(views::Widget* widget) {
// On Windows, widgets can be destroyed in any order. Regardless of which
// widget is destroyed first, remove all observers and hide the popup.
CHECK(widget == parent_widget_ || widget == GetWidget());
// Normally this happens at destruct-time or hide-time, but because it depends
// on |parent_widget_| (which is about to go away), it needs to happen sooner
// in this case.
RemoveWidgetObservers();
// Because the parent widget is about to be destroyed, we null out the weak
// reference to it and protect against possibly accessing it during
// destruction (e.g., by attempting to remove observers).
parent_widget_ = nullptr;
HideController(SuggestionHidingReason::kWidgetChanged);
}
void PopupBaseView::RemoveWidgetObservers() {
if (parent_widget_) {
parent_widget_->RemoveObserver(this);
}
if (views::Widget* widget = GetWidget()) {
widget->RemoveObserver(this);
}
focus_observation_.Reset();
}
void PopupBaseView::UpdateClipPath() {
SkRect local_bounds = gfx::RectToSkRect(GetLocalBounds());
SkScalar radius = SkIntToScalar(GetCornerRadius());
SetClipPath(SkPath::RRect(SkRRect::MakeRectXY(local_bounds, radius, radius)));
}
gfx::Rect PopupBaseView::GetContentAreaBounds() const {
content::WebContents* web_contents = GetWebContents();
if (web_contents) {
return web_contents->GetContainerBounds();
}
// If the |web_contents| is null, simply return an empty rect. The most common
// reason to end up here is that the |web_contents| has been destroyed
// externally, which can happen at any time. This happens fairly commonly on
// Windows (e.g., at shutdown) in particular.
return gfx::Rect();
}
gfx::Rect PopupBaseView::GetTopWindowBounds() const {
views::Widget* widget = views::Widget::GetTopLevelWidgetForNativeView(
delegate_->container_view());
// Find root in window tree.
while (widget && widget->parent()) {
widget = widget->parent();
}
if (widget) {
return widget->GetWindowBoundsInScreen();
}
// If the widget is null, simply return an empty rect. The most common reason
// to end up here is that the NativeView has been destroyed externally, which
// can happen at any time. This happens fairly commonly on Windows (e.g., at
// shutdown) in particular.
return gfx::Rect();
}
gfx::Rect PopupBaseView::GetOptimalPositionAndPlaceArrowOnPopup(
const gfx::Rect& element_bounds,
const gfx::Rect& visible_content_area_bounds,
const gfx::Size& preferred_size,
base::span<const views::BubbleArrowSide> preferred_popup_sides) {
views::BubbleBorder* border = static_cast<views::BubbleBorder*>(
GetWidget()->GetRootView()->GetBorder());
DCHECK(border);
gfx::Rect popup_bounds;
int maximum_pixel_offset_to_center =
base::FeatureList::IsEnabled(features::kAutofillMoreProminentPopup)
? features::kAutofillMoreProminentPopupMaxOffsetToCenterParam.Get()
: kMaximumPixelsToMoveSuggestionToCenter;
// Deduce the arrow and the position.
views::BubbleBorder::Arrow arrow = GetOptimalPopupPlacement(
/*visible_content_area_bounds=*/visible_content_area_bounds,
/*element_bounds=*/element_bounds,
/*popup_preferred_size=*/preferred_size,
/*right_to_left=*/delegate_->GetElementTextDirection() ==
base::i18n::TextDirection::RIGHT_TO_LEFT,
/*scrollbar_width=*/gfx::scrollbar_size(),
/*maximum_pixel_offset_to_center=*/
maximum_pixel_offset_to_center,
/*maximum_width_percentage_to_center=*/
kMaximumWidthPercentageToMoveTheSuggestionToCenter,
/*popup_bounds=*/popup_bounds, preferred_popup_sides,
/*anchor_type=*/delegate_->anchor_type());
// Those values are not supported for adding an arrow.
// Currently, they can not be returned by GetOptimalPopupPlacement().
DCHECK(arrow != views::BubbleBorder::Arrow::NONE);
DCHECK(arrow != views::BubbleBorder::Arrow::FLOAT);
if (show_arrow_pointer_) {
// Set the arrow position to the border.
border->set_arrow(arrow);
border->AddArrowToBubbleCornerAndPointTowardsAnchor(
element_bounds, popup_bounds,
visible_content_area_bounds.y() - kMaxPopupWebContentsTopYOverflow);
}
return popup_bounds;
}
bool PopupBaseView::DoUpdateBoundsAndRedrawPopup() {
gfx::Size preferred_size = GetPreferredSize();
const gfx::Rect content_area_bounds = GetContentAreaBounds();
// TODO(crbug.com/40799454) 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(GetWebContents()) ? top_window_bounds
: content_area_bounds;
// Intersect with the current monitor's work area to avoid showing popups
// outside the screen.
gfx::Rect visible_content_area_bounds =
IntersectWithDisplayBounds(max_bounds_for_popup);
gfx::Rect element_bounds = gfx::ToEnclosingRect(delegate_->element_bounds());
// An element is never outside the content area if it is contained by the
// `visible_content_area_bounds`. This also applies if the element is empty,
// which means that either the height or the width is 0. An element can be
// empty in case the popup is anchored to a caret, which has a 0 width.
if (!visible_content_area_bounds.Contains(element_bounds)) {
// If the element exceeds the visible content area, ensure that the popup
// is still visually attached to the input element.
element_bounds.Intersect(visible_content_area_bounds);
if (element_bounds.IsEmpty()) {
HideController(SuggestionHidingReason::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 one row of the popup should be shown in the bounds of the
// visible content area so that the user notices the presence of the popup.
int item_height =
children().size() > 0 ? children()[0]->GetPreferredSize().height() : 0;
// That function checks whether the popup element has enough place to render
// either to the top of the focused element or to the bottom of the focused
// element.
if (!CanShowDropdownHere(item_height, visible_content_area_bounds,
element_bounds)) {
HideController(SuggestionHidingReason::kInsufficientSpace);
return false;
}
gfx::Rect popup_bounds = GetOptimalPositionAndPlaceArrowOnPopup(
element_bounds, visible_content_area_bounds, preferred_size,
kDefaultPreferredPopupSides);
if (BoundsOverlapWithPictureInPictureWindow(popup_bounds)) {
HideController(
SuggestionHidingReason::kOverlappingWithPictureInPictureWindow);
return false;
}
// Account for the scroll view's border so that the content has enough space.
popup_bounds.Inset(-GetWidget()->GetRootView()->GetInsets());
GetWidget()->SetBounds(popup_bounds);
DeprecatedLayoutImmediately();
UpdateClipPath();
SchedulePaint();
return true;
}
void PopupBaseView::OnNativeFocusChanged(gfx::NativeView focused_now) {
// TODO(crbug.com/330303918): The focus change is triggered sometimes
// (reproduced on a Linux release build, on a debug one - no) with
// `focused_now` == `nullptr` during activatable popup opening, no other
// widget gets focus then and this widget remains active.
// The `!GetWidget()->IsActive()` piece handles this case and prevents
// immediate popup closing.
// Investigate the reason and either fix it on the appropriate side or make
// this TODO a regular comment if it works as intended.
if (GetWidget() && GetWidget()->GetNativeView() != focused_now &&
!GetWidget()->IsActive()) {
HideController(SuggestionHidingReason::kFocusChanged);
}
}
void PopupBaseView::HideController(SuggestionHidingReason reason) {
if (delegate_) {
delegate_->Hide(reason);
}
// This will eventually result in the deletion of |this|, as the delegate
// will hide |this|. See |DoHide| above for an explanation on why the precise
// timing of that deletion is tricky.
}
content::WebContents* PopupBaseView::GetWebContents() const {
if (!delegate_) {
return nullptr;
}
return delegate_->GetWebContents();
}
BEGIN_METADATA(PopupBaseView)
ADD_READONLY_PROPERTY_METADATA(gfx::Rect, ContentAreaBounds)
END_METADATA
} // namespace autofill