blob: 512fccb05223cfa0008f8783070450f958fcdcbe [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/user_education/views/help_bubble_views.h"
#include "base/functional/bind.h"
#include "components/user_education/common/help_bubble/custom_help_bubble.h"
#include "components/user_education/common/user_education_class_properties.h"
#include "components/user_education/common/user_education_events.h"
#include "components/user_education/views/help_bubble_view.h"
#include "components/user_education/views/toggle_tracked_element_attention_utils.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/interaction/element_tracker.h"
#include "ui/base/interaction/framework_specific_implementation.h"
#include "ui/views/accessible_pane_view.h"
#include "ui/views/bubble/bubble_dialog_delegate_view.h"
#include "ui/views/interaction/element_tracker_views.h"
namespace user_education {
namespace {
bool IsFocusInHelpBubble(const views::BubbleDialogDelegateView* bubble) {
#if BUILDFLAG(IS_MAC)
auto* const focused = bubble->GetFocusManager()->GetFocusedView();
return focused && focused->GetWidget() == bubble->GetWidget();
#else
return bubble->GetWidget()->IsActive();
#endif
}
} // namespace
DEFINE_FRAMEWORK_SPECIFIC_METADATA(HelpBubbleViews)
HelpBubbleViews::HelpBubbleViews(
views::BubbleDialogDelegateView* help_bubble_view,
ui::TrackedElement* anchor_element)
: help_bubble_view_(help_bubble_view), anchor_element_(anchor_element) {
CHECK(help_bubble_view);
CHECK(help_bubble_view->GetWidget());
CHECK(anchor_element);
scoped_observation_.Observe(help_bubble_view->GetWidget());
anchor_hidden_subscription_ =
ui::ElementTracker::GetElementTracker()->AddElementHiddenCallback(
anchor_element->identifier(), anchor_element->context(),
base::BindRepeating(&HelpBubbleViews::OnElementHidden,
base::Unretained(this)));
anchor_bounds_changed_subscription_ =
ui::ElementTracker::GetElementTracker()->AddCustomEventCallback(
kHelpBubbleAnchorBoundsChangedEvent, anchor_element->context(),
base::BindRepeating(&HelpBubbleViews::OnElementBoundsChanged,
base::Unretained(this)));
}
HelpBubbleViews::~HelpBubbleViews() {
// Needs to be called here while we still have access to HelpBubbleViews-
// specific logic.
Close(CloseReason::kBubbleDestroyed);
}
// static
views::BubbleBorder::Arrow HelpBubbleViews::TranslateArrow(
HelpBubbleArrow arrow) {
switch (arrow) {
case HelpBubbleArrow::kNone:
return views::BubbleBorder::NONE;
case HelpBubbleArrow::kTopLeft:
return views::BubbleBorder::TOP_LEFT;
case HelpBubbleArrow::kTopRight:
return views::BubbleBorder::TOP_RIGHT;
case HelpBubbleArrow::kBottomLeft:
return views::BubbleBorder::BOTTOM_LEFT;
case HelpBubbleArrow::kBottomRight:
return views::BubbleBorder::BOTTOM_RIGHT;
case HelpBubbleArrow::kLeftTop:
return views::BubbleBorder::LEFT_TOP;
case HelpBubbleArrow::kRightTop:
return views::BubbleBorder::RIGHT_TOP;
case HelpBubbleArrow::kLeftBottom:
return views::BubbleBorder::LEFT_BOTTOM;
case HelpBubbleArrow::kRightBottom:
return views::BubbleBorder::RIGHT_BOTTOM;
case HelpBubbleArrow::kTopCenter:
return views::BubbleBorder::TOP_CENTER;
case HelpBubbleArrow::kBottomCenter:
return views::BubbleBorder::BOTTOM_CENTER;
case HelpBubbleArrow::kLeftCenter:
return views::BubbleBorder::LEFT_CENTER;
case HelpBubbleArrow::kRightCenter:
return views::BubbleBorder::RIGHT_CENTER;
}
}
bool HelpBubbleViews::ToggleFocusForAccessibility() {
// // If the bubble isn't present or can't be meaningfully focused, stop.
if (!help_bubble_view_) {
return false;
}
// If the focus isn't in the help bubble, focus the help bubble.
// Note that if is_focus_in_ancestor_widget is true, then anchor both exists
// and has a widget, so anchor->GetWidget() will always be valid.
if (!IsFocusInHelpBubble(help_bubble_view_)) {
help_bubble_view_->GetWidget()->Activate();
help_bubble_view_->RequestFocus();
return true;
}
auto* const anchor = help_bubble_view_->GetAnchorView();
if (!anchor) {
return false;
}
bool set_focus = false;
if (anchor->GetViewAccessibility().IsAccessibilityFocusable()) {
#if BUILDFLAG(IS_MAC)
// Mac does not automatically pass activation on focus, so we have to do it
// manually.
anchor->GetWidget()->Activate();
#else
// Focus the anchor. We can't request focus for an accessibility-only view
// until we turn on keyboard accessibility for its focus manager.
anchor->GetFocusManager()->SetKeyboardAccessible(true);
#endif
anchor->RequestFocus();
set_focus = true;
} else if (views::IsViewClass<views::AccessiblePaneView>(anchor)) {
// An AccessiblePaneView can receive focus, but is not necessarily itself
// accessibility focusable. Use the built-in functionality for focusing
// elements of AccessiblePaneView instead.
#if BUILDFLAG(IS_MAC)
// Mac does not automatically pass activation on focus, so we have to do it
// manually.
anchor->GetWidget()->Activate();
#else
// You can't focus an accessible pane if it's already in accessibility
// mode, so avoid doing that; the SetPaneFocus() call will go back into
// accessibility navigation mode.
anchor->GetFocusManager()->SetKeyboardAccessible(false);
#endif
set_focus =
static_cast<views::AccessiblePaneView*>(anchor)->SetPaneFocus(nullptr);
}
return set_focus;
}
void HelpBubbleViews::OnAnchorBoundsChanged() {
if (help_bubble_view_) {
help_bubble_view_->OnAnchorBoundsChanged();
}
}
gfx::Rect HelpBubbleViews::GetBoundsInScreen() const {
return help_bubble_view_
? help_bubble_view_->GetWidget()->GetWindowBoundsInScreen()
: gfx::Rect();
}
ui::ElementContext HelpBubbleViews::GetContext() const {
return help_bubble_view_
? views::ElementTrackerViews::GetContextForView(help_bubble_view_)
: ui::ElementContext();
}
bool HelpBubbleViews::AcceleratorPressed(const ui::Accelerator& accelerator) {
if (CanHandleAccelerators()) {
ToggleFocusForAccessibility();
return true;
}
return false;
}
bool HelpBubbleViews::CanHandleAccelerators() const {
return help_bubble_view_ && help_bubble_view_->GetWidget() &&
help_bubble_view_->GetWidget()->IsActive();
}
void HelpBubbleViews::MaybeResetAnchorView() {
if (!help_bubble_view_) {
return;
}
auto* const anchor_view = help_bubble_view_->GetAnchorView();
if (!anchor_view) {
return;
}
anchor_view->SetProperty(kHasInProductHelpPromoKey, false);
MaybeRemoveAttentionStateFromTrackedElement(anchor_view);
}
void HelpBubbleViews::CloseBubbleImpl() {
anchor_hidden_subscription_ = base::CallbackListSubscription();
anchor_bounds_changed_subscription_ = base::CallbackListSubscription();
scoped_observation_.Reset();
MaybeResetAnchorView();
// Reset the anchor view. Closing the widget could cause callbacks which could
// theoretically destroy `this`, so
auto* const help_bubble_view = help_bubble_view_.get();
help_bubble_view_ = nullptr;
anchor_element_ = nullptr;
if (help_bubble_view && help_bubble_view->GetWidget()) {
help_bubble_view->GetWidget()->Close();
}
}
void HelpBubbleViews::OnWidgetDestroying(views::Widget* widget) {
Close(CloseReason::kBubbleElementDestroyed);
}
void HelpBubbleViews::OnElementHidden(ui::TrackedElement* element) {
// There could be other elements with the same identifier as the anchor
// element, so don't close the bubble unless it is actually the anchor.
if (element != anchor_element_) {
return;
}
anchor_hidden_subscription_ = base::CallbackListSubscription();
anchor_bounds_changed_subscription_ = base::CallbackListSubscription();
anchor_element_ = nullptr;
Close(CloseReason::kAnchorHidden);
}
void HelpBubbleViews::OnElementBoundsChanged(ui::TrackedElement* element) {
if (help_bubble_view_ && element == anchor_element_) {
// TODO(dfried): Support arbitrary anchor regions more generally in
// BubbleDialogDelegateViews so that non-help bubble dialogs can be used
// as help bubbles when attached to e.g. WebUI elements.
if (HelpBubbleView::IsHelpBubble(help_bubble_view_)) {
static_cast<HelpBubbleView*>(help_bubble_view_.get())
->SetForceAnchorRect(element->GetScreenBounds());
}
OnAnchorBoundsChanged();
}
}
CustomHelpBubbleViews::CustomHelpBubbleViews(
std::unique_ptr<views::Widget> widget,
views::BubbleDialogDelegateView* bubble,
CustomHelpBubbleUi& ui,
ui::TrackedElement* anchor_element,
std::optional<UserAction> accept_button_action,
std::optional<UserAction> cancel_button_action)
: HelpBubbleViews(bubble, anchor_element),
CustomHelpBubble(ui),
help_bubble_widget_(std::move(widget)),
accept_button_action_(accept_button_action),
cancel_button_action_(cancel_button_action) {
CHECK(help_bubble_widget_);
// Help bubbles should not close on deactivate.
bubble->set_close_on_deactivate(false);
// Help bubbles should always send "ESC Pressed" on escape key, not cancel.
bubble->set_esc_should_cancel_dialog_override(false);
bubble->GetWidget()->MakeCloseSynchronous(base::BindOnce(
&CustomHelpBubbleViews::OnHelpBubbleClosing, base::Unretained(this)));
// Custom help bubble should always have an anchor view.
auto* const anchor_view = bubble->GetAnchorView();
anchor_view->SetProperty(user_education::kHasInProductHelpPromoKey, true);
user_education::MaybeApplyAttentionStateToTrackedElement(anchor_view);
}
CustomHelpBubbleViews::~CustomHelpBubbleViews() {
// Ensure that all closing of help bubbles goes through the same logic path.
//
// Due to upstream logic in HelpBubbleViews, `OnHelpBubbleClosing()` ends up
// getting called in a state where the widget cannot correctly be destroyed,
// leading to a CHECK().
//
// This will be unnecessary when HelpBubbleViews is migrated to ownership mode
// CLIENT_OWNS_WIDGET.
if (help_bubble_widget_) {
help_bubble_widget_->CloseWithReason(
views::Widget::ClosedReason::kUnspecified);
}
}
void CustomHelpBubbleViews::OnHelpBubbleClosing(
views::Widget::ClosedReason reason) {
// The calls below could also destroy `this`, so save off widget in a local.
// This both guarantees that the widget will get properly destroyed at the end
// of this method (as is required by `MakeCloseSynchronous()`) and also
// prevents re-entrancy in the destructor as `help_bubble_widget_` will be
// null.
std::unique_ptr<views::Widget> widget = std::move(help_bubble_widget_);
if (auto* const ui = custom_bubble_ui()) {
switch (reason) {
case views::Widget::ClosedReason::kAcceptButtonClicked:
if (accept_button_action_) {
ui->NotifyUserAction(*accept_button_action_);
}
break;
case views::Widget::ClosedReason::kCancelButtonClicked:
if (cancel_button_action_) {
ui->NotifyUserAction(*cancel_button_action_);
}
break;
case views::Widget::ClosedReason::kCloseButtonClicked:
case views::Widget::ClosedReason::kEscKeyPressed:
ui->NotifyUserAction(UserAction::kCancel);
break;
case views::Widget::ClosedReason::kLostFocus:
case views::Widget::ClosedReason::kUnspecified:
// Do nothing.
break;
}
}
// This is required when responding to `OnHelpBubbleClosing()`; the widget
// must be destroyed before this method returns.
widget.reset();
}
} // namespace user_education