| // 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 |