blob: 626e4bd400c0636eb4e17f8b353eb1e6eb967417 [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 "chrome/browser/actor/ui/handoff_button_controller.h"
#include "base/feature_list.h"
#include "cc/paint/paint_filter.h"
#include "cc/paint/paint_flags.h"
#include "cc/paint/paint_shader.h"
#include "chrome/app/vector_icons/vector_icons.h"
#include "chrome/browser/actor/ui/actor_ui_metrics.h"
#include "chrome/browser/actor/ui/actor_ui_tab_controller.h"
#include "chrome/browser/actor/ui/actor_ui_window_controller.h"
#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
#include "chrome/browser/ui/color/chrome_color_id.h"
#include "chrome/browser/ui/views/interaction/browser_elements_views.h"
#include "chrome/common/chrome_features.h"
#include "chrome/grit/generated_resources.h"
#include "components/tabs/public/tab_interface.h"
#include "components/vector_icons/vector_icons.h"
#include "third_party/skia/include/core/SkColor.h"
#include "third_party/skia/include/core/SkRRect.h"
#include "third_party/skia/include/effects/SkGradient.h"
#include "third_party/skia/include/effects/SkImageFilters.h"
#include "ui/base/cursor/cursor.h"
#include "ui/base/cursor/mojom/cursor_type.mojom-shared.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.h"
#include "ui/color/color_id.h"
#include "ui/color/color_provider.h"
#include "ui/events/types/event_type.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/geometry/skia_conversions.h"
#include "ui/views/border.h"
#include "ui/views/bubble/bubble_border.h"
#include "ui/views/bubble/bubble_frame_view.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/controls/button/label_button_border.h"
#include "ui/views/controls/webview/webview.h"
#include "ui/views/style/typography.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/widget/widget_delegate.h"
#if BUILDFLAG(ENABLE_GLIC)
#include "chrome/browser/glic/public/glic_keyed_service.h"
#include "chrome/browser/glic/public/glic_keyed_service_factory.h"
#endif
namespace {
// A fixed vertical offset from the top of the window, used when the tab
// strip is not visible.
constexpr int kHandoffButtonTopOffset = 8;
constexpr int kHandoffButtonPreferredHeight = 44;
constexpr float kHandoffButtonShadowMargin = 15.0f;
constexpr float kBackgroundInset = 2.0f;
constexpr float kHandoffButtonCornerRadius = 48.0f;
constexpr int kHandoffButtonIconSize = 20;
constexpr gfx::Insets kHandoffButtonContentPadding =
gfx::Insets::TLBR(10, 10, 10, 14);
// A customized LabelButton that shows a hand cursor on hover.
class HandoffLabelButton : public views::LabelButton {
METADATA_HEADER(HandoffLabelButton, views::LabelButton)
public:
using views::LabelButton::LabelButton;
~HandoffLabelButton() override = default;
// views::View:
ui::Cursor GetCursor(const ui::MouseEvent& event) override {
return ui::mojom::CursorType::kHand;
}
};
BEGIN_METADATA(HandoffLabelButton)
END_METADATA
// A custom BubbleFrameView that paints a gradient border.
class GradientBubbleFrameView : public views::BubbleFrameView {
METADATA_HEADER(GradientBubbleFrameView, views::BubbleFrameView)
public:
GradientBubbleFrameView(const gfx::Insets& total_insets,
views::BubbleBorder::Arrow arrow_location,
const gfx::RoundedCornersF& corners)
: views::BubbleFrameView(gfx::Insets(), total_insets),
corner_radius_(corners) {
auto border = std::make_unique<views::BubbleBorder>(
arrow_location, views::BubbleBorder::Shadow::NO_SHADOW);
border->set_draw_border_stroke(false);
border->set_rounded_corners(corners);
SetBubbleBorder(std::move(border));
}
~GradientBubbleFrameView() override = default;
// views::View:
void OnPaint(gfx::Canvas* canvas) override {
constexpr float kShadowBlurSigma = 5.0f;
constexpr float kShadowOffsetX = 0.0f;
constexpr float kShadowOffsetY = 3.0f;
gfx::RectF button_bounds_f(GetLocalBounds());
button_bounds_f.Inset(kHandoffButtonShadowMargin);
const float shadow_corner_radius = corner_radius_.upper_left();
const float corner_radius = corner_radius_.upper_left();
cc::PaintCanvas* paint_canvas = canvas->sk_canvas();
SkRRect rrect;
rrect.setRectXY(gfx::RectFToSkRect(button_bounds_f), corner_radius,
corner_radius);
// Draw the shadow
{
cc::PaintCanvasAutoRestore auto_restore(paint_canvas, true);
paint_canvas->translate(kShadowOffsetX, kShadowOffsetY);
cc::PaintFlags shadow_flags;
shadow_flags.setAntiAlias(true);
SkPoint center = SkPoint::Make(button_bounds_f.CenterPoint().x(),
button_bounds_f.CenterPoint().y());
std::vector<SkColor> colors;
if (base::FeatureList::IsEnabled(features::kActorUiThemed)) {
SkColor primary_color =
GetColorProvider()->GetColor(kColorActorUiHandoffButtonBorder);
colors = {primary_color, primary_color, primary_color, primary_color};
} else {
colors = {SkColorSetARGB(255, 79, 161, 255),
SkColorSetARGB(255, 79, 161, 255),
SkColorSetARGB(255, 52, 107, 241),
SkColorSetARGB(255, 52, 107, 241)};
}
const SkScalar pos[] = {0.0f, 0.4f, 0.6f, 1.0f};
std::vector<SkColor4f> color4fs;
for (const auto& color : colors) {
color4fs.push_back(SkColor4f::FromColor(color));
}
auto shader = cc::PaintShader::MakeSweepGradient(
center.x(), center.y(), color4fs.data(), pos, color4fs.size(),
SkTileMode::kClamp, 0, 360);
shadow_flags.setShader(std::move(shader));
auto blur_filter = sk_make_sp<cc::BlurPaintFilter>(
kShadowBlurSigma, kShadowBlurSigma, SkTileMode::kDecal, nullptr);
shadow_flags.setImageFilter(std::move(blur_filter));
paint_canvas->drawRRect(rrect, shadow_flags);
}
// Create a slightly smaller rectangle for the background.
gfx::RectF background_bounds_f = button_bounds_f;
background_bounds_f.Inset(kBackgroundInset);
const float background_corner_radius =
shadow_corner_radius > kBackgroundInset
? shadow_corner_radius - kBackgroundInset
: 0.f;
SkRRect background_rrect;
background_rrect.setRectXY(gfx::RectFToSkRect(background_bounds_f),
background_corner_radius,
background_corner_radius);
cc::PaintFlags background_flags;
background_flags.setAntiAlias(true);
background_flags.setStyle(cc::PaintFlags::kFill_Style);
background_flags.setColor(
GetColorProvider()->GetColor(ui::kColorTextfieldBackground));
paint_canvas->drawRRect(background_rrect, background_flags);
}
private:
gfx::RoundedCornersF corner_radius_;
};
BEGIN_METADATA(GradientBubbleFrameView)
END_METADATA
std::unique_ptr<views::FrameView> CreateHandoffButtonFrameView(
views::Widget* widget) {
const gfx::Insets total_insets =
gfx::Insets(kHandoffButtonShadowMargin) + gfx::Insets(kBackgroundInset);
const gfx::RoundedCornersF corners(kHandoffButtonCornerRadius);
auto frame_view = std::make_unique<GradientBubbleFrameView>(
total_insets, views::BubbleBorder::Arrow::NONE, corners);
frame_view->SetBackgroundColor(ui::kColorTextfieldBackground);
return frame_view;
}
} // namespace
namespace actor::ui {
using enum HandoffButtonState::ControlOwnership;
using ::ui::ImageModel;
HandoffButtonWidget::HandoffButtonWidget() = default;
HandoffButtonWidget::~HandoffButtonWidget() = default;
void HandoffButtonWidget::SetHoveredCallback(
base::RepeatingCallback<void(bool)> callback) {
hover_callback_ = std::move(callback);
}
void HandoffButtonWidget::OnMouseEvent(::ui::MouseEvent* event) {
switch (event->type()) {
case ::ui::EventType::kMouseEntered:
hover_callback_.Run(true);
break;
case ::ui::EventType::kMouseExited:
hover_callback_.Run(false);
break;
default:
break;
}
views::Widget::OnMouseEvent(event);
}
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(HandoffButtonController,
kHandoffButtonElementId);
HandoffButtonController::HandoffButtonController(
views::View* anchor_view,
ActorUiWindowController* window_controller)
: anchor_view_(anchor_view), window_controller_(window_controller) {}
HandoffButtonController::~HandoffButtonController() = default;
void HandoffButtonController::UpdateState(HandoffButtonState state,
bool is_visible,
base::OnceClosure callback) {
if (!state.is_active) {
CloseButton(views::Widget::ClosedReason::kUnspecified);
std::move(callback).Run();
return;
}
is_visible_ = is_visible;
ownership_ = state.controller;
bool is_immersive = window_controller_->IsImmersiveModeEnabled();
bool is_pinned = window_controller_->IsToolbarPinned();
// Check if a layout change occurred that requires re-anchoring (immersive
// mode toggled on or off, or a toolbar pin state change while in immersive
// mode).
bool layout_changed = (is_immersive != was_immersive_) ||
(is_immersive && (is_pinned != was_toolbar_pinned_));
if (widget_ && layout_changed) {
view_observer_.Reset();
button_view_ = nullptr;
widget_.reset();
delegate_.reset();
}
was_immersive_ = is_immersive;
was_toolbar_pinned_ = is_pinned;
std::u16string text;
std::u16string a11y_text;
ImageModel icon;
// TODO(crbug.com/454932877): Clean up kClient state changes if button removal
// for kClient state is finalized.
switch (state.controller) {
case kActor:
text = l10n_util::GetStringUTF16(IDS_HANDOFF_TAKE_OVER_TASK_LABEL);
a11y_text =
l10n_util::GetStringUTF16(IDS_HANDOFF_TAKE_OVER_TASK_A11Y_LABEL);
icon = ImageModel::FromVectorIcon(vector_icons::kPauseIcon,
::ui::kColorLabelForeground,
kHandoffButtonIconSize);
break;
case kClient:
text = l10n_util::GetStringUTF16(IDS_HANDOFF_GIVE_TASK_BACK_LABEL);
a11y_text =
l10n_util::GetStringUTF16(IDS_HANDOFF_GIVE_TASK_BACK_A11Y_LABEL);
icon = ImageModel::FromVectorIcon(vector_icons::kPlayArrowIcon,
::ui::kColorLabelForeground,
kHandoffButtonIconSize);
break;
}
// If the widget doesn't exist, create it with the correct initial state.
if (!widget_) {
CreateAndShowButton(text, a11y_text, icon);
} else if (button_view_) {
// If it already exists, update its content.
button_view_->SetText(text);
button_view_->SetAccessibleDescription(a11y_text);
button_view_->SetImageModel(views::Button::STATE_NORMAL, icon);
UpdateBounds();
}
if (is_immersive) {
widget_->SetZOrderLevel(::ui::ZOrderLevel::kFloatingUIElement);
} else {
widget_->SetZOrderLevel(::ui::ZOrderLevel::kNormal);
}
if (is_visible_) {
widget_->ShowInactive();
} else {
widget_->Hide();
}
std::move(callback).Run();
}
void HandoffButtonController::CreateAndShowButton(
const std::u16string& text,
const std::u16string& a11y_text,
const ImageModel& icon) {
CHECK(!widget_);
// Create the button view.
auto button_view = std::make_unique<HandoffLabelButton>(
base::BindRepeating(&HandoffButtonController::OnButtonPressed,
weak_ptr_factory_.GetWeakPtr()),
text);
button_view_ = button_view.get();
button_view_->SetAccessibleDescription(a11y_text);
button_view_->SetEnabledTextColors(::ui::kColorLabelForeground);
button_view_->SetImageModel(views::Button::STATE_NORMAL, icon);
button_view_->SetProperty(views::kElementIdentifierKey,
kHandoffButtonElementId);
button_view_->SetLabelStyle(views::style::STYLE_BODY_3_MEDIUM);
button_view_->SetBorder(views::CreatePaddedBorder(
button_view_->CreateDefaultBorder(),
kHandoffButtonContentPadding - gfx::Insets(kBackgroundInset)));
view_observer_.Observe(button_view_.get());
auto widget_delegate = std::make_unique<views::WidgetDelegate>();
widget_delegate->SetContentsView(std::move(button_view));
widget_delegate->SetModalType(::ui::mojom::ModalType::kNone);
widget_delegate->SetAccessibleWindowRole(ax::mojom::Role::kAlert);
widget_delegate->SetShowCloseButton(false);
widget_delegate->SetFrameViewFactory(
base::BindRepeating(&CreateHandoffButtonFrameView));
delegate_ = std::move(widget_delegate);
// Create the Widget using the delegate.
auto widget = std::make_unique<HandoffButtonWidget>();
views::Widget::InitParams params(
views::Widget::InitParams::Ownership::CLIENT_OWNS_WIDGET,
views::Widget::InitParams::TYPE_BUBBLE);
params.delegate = delegate_.get();
params.parent = anchor_view_->GetWidget()->GetNativeView();
params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent;
params.remove_standard_frame = true;
params.shadow_type = views::Widget::InitParams::ShadowType::kNone;
params.autosize = false;
params.name = "HandoffButtonWidget";
widget->Init(std::move(params));
widget_ = std::move(widget);
widget_->SetHoveredCallback(
base::BindRepeating(&HandoffButtonController::UpdateButtonHoverStatus,
weak_ptr_factory_.GetWeakPtr()));
widget_->MakeCloseSynchronous(
base::BindOnce(&HandoffButtonController::OnWidgetDestroying,
weak_ptr_factory_.GetWeakPtr()));
UpdateBounds();
}
gfx::Rect HandoffButtonController::GetHandoffButtonBounds() {
CHECK(widget_);
CHECK(anchor_view_);
gfx::Size preferred_size = widget_->GetContentsView()->GetPreferredSize();
preferred_size.set_height(kHandoffButtonPreferredHeight);
const gfx::Rect anchor_bounds = anchor_view_->GetBoundsInScreen();
const int x =
anchor_bounds.x() + (anchor_bounds.width() - preferred_size.width()) / 2;
// Calculate the Y coordinate based on tab strip visibility.
bool is_tab_strip_visible =
tab_interface_
? tab_interface_->GetBrowserWindowInterface()->IsTabStripVisible()
: false;
const int y =
is_tab_strip_visible
// Vertically center the button on the top edge of the anchor.
? anchor_bounds.y() - kHandoffButtonPreferredHeight
// Position with a fixed offset from the top of the anchor.
: anchor_bounds.y() - kHandoffButtonTopOffset;
return gfx::Rect({x, y}, preferred_size);
}
void HandoffButtonController::OnWidgetDestroying(
views::Widget::ClosedReason reason) {
view_observer_.Reset();
button_view_ = nullptr;
widget_.reset();
delegate_.reset();
}
void HandoffButtonController::CloseButton(views::Widget::ClosedReason reason) {
// Before closing the button, reset hover and focus status to prevent stale
// state propagation.
if (base::FeatureList::IsEnabled(
features::kGlicHandoffButtonResetFocusAndHoverStatus)) {
UpdateButtonHoverStatus(false);
UpdateButtonFocusStatus(false);
}
if (widget_ && !widget_->IsClosed()) {
widget_->CloseWithReason(reason);
}
}
void HandoffButtonController::UpdateButtonHoverStatus(bool is_hovered) {
is_hovering_ = is_hovered;
if (auto* tab_controller = GetTabController()) {
tab_controller->OnHandoffButtonHoverStatusChanged();
}
}
void HandoffButtonController::OnButtonPressed() {
// If the Actor is currently in control, pressing the button
// flips the state and pauses the task.
if (auto* tab_controller = GetTabController()) {
if (ownership_ == kActor) {
tab_controller->SetActorTaskPaused();
#if BUILDFLAG(ENABLE_GLIC)
BrowserWindowInterface* bwi = tab_interface_->GetBrowserWindowInterface();
auto* glic_service =
glic::GlicKeyedServiceFactory::GetGlicKeyedService(bwi->GetProfile());
if (glic_service) {
glic_service->ToggleUI(bwi, /*prevent_close=*/true,
glic::mojom::InvocationSource::kHandoffButton);
}
#endif
} else {
tab_controller->SetActorTaskResume();
}
}
actor::ui::LogHandoffButtonClick(ownership_);
}
void HandoffButtonController::UpdateBounds() {
if (widget_) {
widget_->SetBounds(GetHandoffButtonBounds());
}
}
base::ScopedClosureRunner HandoffButtonController::RegisterTabInterface(
tabs::TabInterface* tab_interface) {
tab_interface_ = tab_interface;
return base::ScopedClosureRunner(
base::BindOnce(&HandoffButtonController::UnregisterTabInterface,
weak_ptr_factory_.GetWeakPtr()));
}
void HandoffButtonController::UnregisterTabInterface() {
tab_interface_ = nullptr;
UpdateState(HandoffButtonState(), /*is_visible=*/false, base::DoNothing());
}
ActorUiTabControllerInterface* HandoffButtonController::GetTabController() {
return tab_interface_
? ActorUiTabControllerInterface::From(tab_interface_.get())
: nullptr;
}
bool HandoffButtonController::IsHovering() {
return is_hovering_;
}
void HandoffButtonController::UpdateButtonFocusStatus(bool is_focused) {
is_focused_ = is_focused;
if (auto* tab_controller = GetTabController()) {
tab_controller->OnHandoffButtonFocusStatusChanged();
}
}
void HandoffButtonController::OnViewFocused(views::View* observed_view) {
UpdateButtonFocusStatus(/*is_focused=*/true);
}
void HandoffButtonController::OnViewBlurred(views::View* observed_view) {
UpdateButtonFocusStatus(/*is_focused=*/false);
}
bool HandoffButtonController::IsFocused() {
return is_focused_;
}
base::WeakPtr<HandoffButtonController> HandoffButtonController::GetWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
} // namespace actor::ui