blob: df534624e5759beff88409873d5241a4a450d26d [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/glic/browser_ui/glic_tab_underline_view.h"
#include "base/debug/crash_logging.h"
#include "cc/paint/paint_flags.h"
#include "chrome/browser/glic/public/glic_keyed_service.h"
#include "chrome/browser/glic/public/glic_keyed_service_factory.h"
#include "chrome/browser/glic/widget/glic_window_controller.h"
#include "chrome/browser/themes/theme_service.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/browser/ui/views/tabs/tab.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/favicon_size.h"
#include "ui/views/view_class_properties.h"
namespace glic {
namespace {
// The total duration of the underline's animation cycle.
constexpr static base::TimeDelta kCycleDuration = base::Milliseconds(3000);
// The width to use for the underline when tabs reach a small size.
constexpr static int kSmallUnderlineWidth = gfx::kFaviconSize;
// The width to use for the underline at the smallest tab sizes when tab
// contents begin to be clipped.
constexpr static int kMinUnderlineWidth = kSmallUnderlineWidth - 4;
// The threshold for tab width at which `kMinUnderlineWidth` should be used.
constexpr static int kMinimumTabWidthThreshold = 42;
// The height of the underline effect.
constexpr static int kEffectHeight = 2;
// The radius to use for rounded corners of the underline effect.
constexpr static float kCornerRadius = kEffectHeight / 2.0f;
} // namespace
DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(GlicTabUnderlineView,
kGlicTabUnderlineElementId);
GlicTabUnderlineView::Factory* GlicTabUnderlineView::Factory::factory_ =
nullptr;
std::unique_ptr<GlicTabUnderlineView> GlicTabUnderlineView::Factory::Create(
Browser* browser,
Tab* tab) {
if (factory_) [[unlikely]] {
return factory_->CreateUnderlineView(browser, tab);
}
return base::WrapUnique(
new GlicTabUnderlineView(browser, tab, /*tester=*/nullptr));
}
// The following logic makes many references to "pinned" tabs. All of these
// refer to tabs that are selected to be shared with Gemini under the glic
// multitab feature. This is different from the older existing notion of
// "pinned" tabs in the tabstrip, which is the UI treatment that fixes a Tab
// view to one side with a reduced visual. Separate terminology should be used
// for the glic multitab concept in order to disambiguate, but landed code
// already adopts the "pinning" term and so that continues to be used here.
// TODO(crbug.com/433131600): update glic multitab sharing code to use less
// conflicting terminology.
class GlicTabUnderlineView::UnderlineViewUpdater
: public GlicWindowController::StateObserver {
public:
UnderlineViewUpdater(Browser* browser, GlicTabUnderlineView* underline_view)
: underline_view_(underline_view), browser_(browser) {
auto* glic_service = GetGlicKeyedService();
GlicSharingManager& sharing_manager = glic_service->sharing_manager();
// Subscribe to changes in the focused tab.
focus_change_subscription_ =
sharing_manager.AddFocusedTabChangedCallback(base::BindRepeating(
&GlicTabUnderlineView::UnderlineViewUpdater::OnFocusedTabChanged,
base::Unretained(this)));
// Subscribe to changes in the context access indicator status.
indicator_change_subscription_ =
glic_service->AddContextAccessIndicatorStatusChangedCallback(
base::BindRepeating(&GlicTabUnderlineView::UnderlineViewUpdater::
OnIndicatorStatusChanged,
base::Unretained(this)));
// Subscribe to changes in the set of pinned tabs.
pinned_tabs_change_subscription_ =
sharing_manager.AddPinnedTabsChangedCallback(base::BindRepeating(
&GlicTabUnderlineView::UnderlineViewUpdater::OnPinnedTabsChanged,
base::Unretained(this)));
// Observe changes in the floaty state.
glic_service->window_controller().AddStateObserver(this);
// Subscribe to when new requests are made by glic.
user_input_submitted_subscription_ =
glic_service->AddUserInputSubmittedCallback(base::BindRepeating(
&GlicTabUnderlineView::UnderlineViewUpdater::OnUserInputSubmitted,
base::Unretained(this)));
}
UnderlineViewUpdater(const UnderlineViewUpdater&) = delete;
UnderlineViewUpdater& operator=(const UnderlineViewUpdater&) = delete;
~UnderlineViewUpdater() override {
GetGlicKeyedService()->window_controller().RemoveStateObserver(this);
}
// Called when the focused tab changes with the focused tab data object.
// This code interprets the tab data to determine how underline_view_'s tab
// was involved.
void OnFocusedTabChanged(const FocusedTabData& focused_tab_data) {
tabs::TabInterface* tab = focused_tab_data.focus();
auto* previous_focus = glic_current_focused_contents_.get();
if (tab) {
glic_current_focused_contents_ = tab->GetContents()->GetWeakPtr();
} else {
glic_current_focused_contents_.reset();
}
auto* current_focus = glic_current_focused_contents_.get();
base::WeakPtr<content::WebContents> underline_contents;
if (auto tab_interface = GetTabInterface()) {
underline_contents = tab_interface->GetContents()->GetWeakPtr();
} else {
return;
}
bool focus_changed = previous_focus != current_focus;
bool tab_switch =
previous_focus && glic_current_focused_contents_ && focus_changed;
bool this_tab_gained_focus =
(underline_contents.get() == current_focus) && focus_changed;
bool this_tab_lost_focus =
(underline_contents.get() == previous_focus) && focus_changed;
bool window_gained_focus =
!previous_focus && glic_current_focused_contents_;
bool window_lost_focus = previous_focus && !glic_current_focused_contents_;
if (tab_switch) {
if (this_tab_gained_focus) {
UpdateUnderlineView(
UpdateUnderlineReason::kFocusedTabChanged_TabGainedFocus);
} else if (this_tab_lost_focus) {
UpdateUnderlineView(
UpdateUnderlineReason::kFocusedTabChanged_TabLostFocus);
} else {
UpdateUnderlineView(
UpdateUnderlineReason::kFocusedTabChanged_NoFocusChange);
}
} else {
if (window_gained_focus) {
UpdateUnderlineView(
UpdateUnderlineReason::kFocusedTabChanged_ChromeGainedFocus);
} else if (window_lost_focus) {
UpdateUnderlineView(
UpdateUnderlineReason::kFocusedTabChanged_ChromeLostFocus);
}
}
}
// Called when the client changes the context access indicator status. This
// happens when the sharing control in the floaty is toggled, and implicitly
// when floaty is [back/fore]grounded while sharing is on.
void OnIndicatorStatusChanged(bool enabled) {
if (context_access_indicator_enabled_ == enabled) {
return;
}
context_access_indicator_enabled_ = enabled;
UpdateUnderlineView(
context_access_indicator_enabled_
? UpdateUnderlineReason::kContextAccessIndicatorOn
: UpdateUnderlineReason::kContextAccessIndicatorOff);
}
// Called when the glic set of pinned tabs changes.
void OnPinnedTabsChanged(
const std::vector<content::WebContents*>& pinned_contents) {
if (!GetTabInterface()) {
// If the TabInterface is invalid at this point, there is no relevant UI
// to handle.
return;
}
// Triggering is handled based on whether the tab is in the pinned set.
if (IsUnderlineTabPinned()) {
UpdateUnderlineView(
UpdateUnderlineReason::kPinnedTabsChanged_TabInPinnedSet);
return;
}
UpdateUnderlineView(
UpdateUnderlineReason::kPinnedTabsChanged_TabNotInPinnedSet);
}
// The glic panel state must be separately observed because underlines of
// pinned tabs uniquely respond to showing/hiding of the glic panel.
void PanelStateChanged(
const glic::mojom::PanelState& panel_state,
const GlicWindowController::PanelStateContext& context) override {
UpdateUnderlineView(
panel_state.kind == mojom::PanelState::Kind::kHidden
? UpdateUnderlineReason::kPanelStateChanged_PanelHidden
: UpdateUnderlineReason::kPanelStateChanged_PanelShowing);
}
void OnUserInputSubmitted() {
UpdateUnderlineView(UpdateUnderlineReason::kUserInputSubmitted);
}
private:
// Types of updates to the tab underline UI effect given changes in relevant
// triggering signals, including tab focus, glic sharing controls, pinned tabs
// and the floaty panel.
enum class UpdateUnderlineReason {
kContextAccessIndicatorOn = 0,
kContextAccessIndicatorOff,
// Tab focus change not involving this underline.
kFocusedTabChanged_NoFocusChange,
// This underline's tab gained focus.
kFocusedTabChanged_TabGainedFocus,
// This underline's tab lost focus.
kFocusedTabChanged_TabLostFocus,
kFocusedTabChanged_ChromeGainedFocus,
kFocusedTabChanged_ChromeLostFocus,
// Chanes were made to the set of pinned of tabs.
kPinnedTabsChanged_TabInPinnedSet,
kPinnedTabsChanged_TabNotInPinnedSet,
// Events related to the glic panel's state.
kPanelStateChanged_PanelShowing,
kPanelStateChanged_PanelHidden,
kUserInputSubmitted,
};
GlicKeyedService* GetGlicKeyedService() {
return GlicKeyedServiceFactory::GetGlicKeyedService(browser_->GetProfile());
}
// Returns the TabInterface corresponding to `underline_view_`, if it is
// valid.
base::WeakPtr<tabs::TabInterface> GetTabInterface() {
if (underline_view_ && underline_view_->tab_) {
if (auto tab_interface = underline_view_->tab_->data().tab_interface) {
return tab_interface;
}
}
return nullptr;
}
bool IsUnderlineTabPinned() {
if (auto tab_interface = GetTabInterface()) {
if (auto* glic_service = GetGlicKeyedService()) {
return glic_service->sharing_manager().IsTabPinned(
tab_interface->GetHandle());
}
}
return false;
}
bool IsUnderlineTabSharedThroughActiveFollow() {
if (auto tab_interface = GetTabInterface()) {
if (auto* glic_service = GetGlicKeyedService()) {
return (glic_service->sharing_manager().GetFocusedTabData().focus() ==
tab_interface.get()) &&
context_access_indicator_enabled_;
}
}
return false;
}
// Trigger the necessary UI effect, primarily based on the given
// `UpdateUnderlineReason` and whether or not `underline_view_`'s tab is
// being shared via pinning or active following.
void UpdateUnderlineView(UpdateUnderlineReason reason) {
AddReasonForDebugging(reason);
auto reasons_string = UpdateReasonsToString();
SCOPED_CRASH_KEY_STRING1024("crbug-398319435", "update_reasons",
reasons_string);
SCOPED_CRASH_KEY_BOOL("crbug-398319435", "access_indicator",
context_access_indicator_enabled_);
SCOPED_CRASH_KEY_BOOL("crbug-398319435", "glic_focused_contents",
!!glic_current_focused_contents_);
SCOPED_CRASH_KEY_BOOL("crbug-398319435", "is_glic_window_showing",
IsGlicWindowShowing());
switch (reason) {
case UpdateUnderlineReason::kContextAccessIndicatorOn: {
// Active follow tab underline should be newly shown, pinned tabs should
// re-animate or be newly shown if not already visible.
if (IsUnderlineTabSharedThroughActiveFollow()) {
ShowAndAnimateUnderline();
}
ShowOrAnimatePinnedUnderline();
break;
}
case UpdateUnderlineReason::kContextAccessIndicatorOff: {
// Underline should be hidden, with exception to pinned tabs while the
// glic panel remains open.
if (IsUnderlineTabPinned() && IsGlicWindowShowing()) {
break;
}
HideUnderline();
break;
}
case UpdateUnderlineReason::kFocusedTabChanged_NoFocusChange: {
// Pinned tab underlines should re-animate if active follow sharing is
// on.
if (context_access_indicator_enabled_ && IsUnderlineTabPinned()) {
AnimateUnderline();
}
break;
}
case UpdateUnderlineReason::kFocusedTabChanged_TabGainedFocus: {
// Underline visibility corresponds to the focused tab during active
// follow. Pinned tabs should not react as the set of shared tabs has
// not changed.
if (IsUnderlineTabSharedThroughActiveFollow()) {
ShowAndAnimateUnderline();
}
break;
}
case UpdateUnderlineReason::kFocusedTabChanged_TabLostFocus: {
// Underline visibility corresponds to the focused tab during active
// follow. Pinned tabs should re-animate if the set of shared tabs has
// changed
if (IsUnderlineTabPinned() && context_access_indicator_enabled_) {
AnimateUnderline();
} else if (!IsUnderlineTabPinned()) {
HideUnderline();
}
break;
}
case UpdateUnderlineReason::kFocusedTabChanged_ChromeGainedFocus:
// Active follow tab underline should be newly shown, pinned tabs should
// re-animate or be newly shown if not already visible.
if (IsUnderlineTabSharedThroughActiveFollow()) {
ShowAndAnimateUnderline();
}
ShowOrAnimatePinnedUnderline();
break;
case UpdateUnderlineReason::kFocusedTabChanged_ChromeLostFocus:
// Underline should be hidden, with exception to pinned tabs.
if (!IsUnderlineTabPinned()) {
HideUnderline();
}
break;
case UpdateUnderlineReason::kPinnedTabsChanged_TabInPinnedSet:
// If `underline_view_` is not visible, then this tab was just added to
// the set of pinned tabs.
if (!underline_view_->IsShowing()) {
// Pinned tab underlines should only be visible while the glic panel
// is open.
if (IsGlicWindowShowing()) {
ShowAndAnimateUnderline();
}
} else {
// This tab was already pinned - re-animate to reflect the change in
// the set of pinned tabs.
AnimateUnderline();
}
break;
case UpdateUnderlineReason::kPinnedTabsChanged_TabNotInPinnedSet:
// Re-animate to reflect the change in the set of pinned tabs.
if (IsUnderlineTabSharedThroughActiveFollow()) {
AnimateUnderline();
return;
}
// This tab may have just been removed from the pinned set.
HideUnderline();
break;
case UpdateUnderlineReason::kPanelStateChanged_PanelShowing:
// Visibility of underlines of pinned tabs should follow visibility of
// the glic panel.
if (IsUnderlineTabPinned()) {
ShowAndAnimateUnderline();
}
break;
case UpdateUnderlineReason::kPanelStateChanged_PanelHidden:
// Visibility of underlines of pinned tabs should follow visibility of
// the glic panel.
if (IsUnderlineTabPinned()) {
HideUnderline();
}
break;
case UpdateUnderlineReason::kUserInputSubmitted:
if (underline_view_->IsShowing()) {
AnimateUnderline();
}
break;
}
}
// Off to On. Throw away everything we have and start the animation from
// the beginning.
void ShowAndAnimateUnderline() {
underline_view_->StopShowing();
underline_view_->Show();
}
void HideUnderline() {
if (underline_view_->IsShowing()) {
underline_view_->StartRampingDown();
}
}
// Replay the animation without hiding and re-showing the view.
void AnimateUnderline() { underline_view_->ResetAnimationCycle(); }
void ShowOrAnimatePinnedUnderline() {
// Pinned underlines should never be visible if the glic window is closed.
if (!IsUnderlineTabPinned() || !IsGlicWindowShowing()) {
return;
}
if (underline_view_->IsShowing()) {
AnimateUnderline();
} else {
ShowAndAnimateUnderline();
}
}
bool IsGlicWindowShowing() const {
return underline_view_->GetGlicService()->window_controller().IsShowing();
}
bool IsTabInCurrentWindow(const content::WebContents* tab) const {
auto* model = browser_->GetTabStripModel();
CHECK(model);
int index = model->GetIndexOfWebContents(tab);
return index != TabStripModel::kNoTab;
}
std::string UpdateReasonToString(UpdateUnderlineReason reason) {
switch (reason) {
case UpdateUnderlineReason::kContextAccessIndicatorOn:
return "IndicatorOn";
case UpdateUnderlineReason::kContextAccessIndicatorOff:
return "IndicatorOff";
case UpdateUnderlineReason::kFocusedTabChanged_NoFocusChange:
return "TabFocusChange";
case UpdateUnderlineReason::kFocusedTabChanged_TabGainedFocus:
return "TabGainedFocus";
case UpdateUnderlineReason::kFocusedTabChanged_TabLostFocus:
return "TabLostFocus";
case UpdateUnderlineReason::kFocusedTabChanged_ChromeGainedFocus:
return "ChromeGainedFocus";
case UpdateUnderlineReason::kFocusedTabChanged_ChromeLostFocus:
return "ChromeLostFocus";
case UpdateUnderlineReason::kPinnedTabsChanged_TabInPinnedSet:
return "TabInPinnedSet";
case UpdateUnderlineReason::kPinnedTabsChanged_TabNotInPinnedSet:
return "TabNotInPinnedSet";
case UpdateUnderlineReason::kPanelStateChanged_PanelShowing:
return "PanelShowing";
case UpdateUnderlineReason::kPanelStateChanged_PanelHidden:
return "PanelHidden";
case UpdateUnderlineReason::kUserInputSubmitted:
return "UserInputSubmitted";
}
}
void AddReasonForDebugging(UpdateUnderlineReason reason) {
underline_update_reasons_.push_back(UpdateReasonToString(reason));
if (underline_update_reasons_.size() > kNumReasonsToKeep) {
underline_update_reasons_.pop_front();
}
}
std::string UpdateReasonsToString() const {
std::ostringstream oss;
for (const auto& r : underline_update_reasons_) {
oss << r << ",";
}
return oss.str();
}
// Back pointer to the owner. Guaranteed to outlive `this`.
const raw_ptr<GlicTabUnderlineView> underline_view_;
// Owned by `BrowserView`. Outlives all the children of the `BrowserView`.
const raw_ptr<BrowserWindowInterface> browser_;
// Tracked states and their subscriptions.
base::WeakPtr<content::WebContents> glic_current_focused_contents_;
base::CallbackListSubscription focus_change_subscription_;
bool context_access_indicator_enabled_ = false;
base::CallbackListSubscription indicator_change_subscription_;
base::CallbackListSubscription pinned_tabs_change_subscription_;
base::CallbackListSubscription user_input_submitted_subscription_;
static constexpr size_t kNumReasonsToKeep = 10u;
std::list<std::string> underline_update_reasons_;
};
GlicTabUnderlineView::GlicTabUnderlineView(Browser* browser,
Tab* tab,
std::unique_ptr<Tester> tester)
: GlicAnimatedEffectView(browser, std::move(tester)),
updater_(std::make_unique<UnderlineViewUpdater>(browser, this)),
tab_(tab) {
SetProperty(views::kElementIdentifierKey, kGlicTabUnderlineElementId);
auto* glic_service =
GlicKeyedServiceFactory::GetGlicKeyedService(browser->GetProfile());
// Post-initialization updates. Don't do the update in the updater's ctor
// because at that time GlicTabUnderlineView isn't fully initialized, which
// can lead to undefined behavior.
//
// Fetch the latest context access indicator status from service. We can't
// assume the WebApp always updates the status on the service (thus the new
// subscribers not getting the latest value).
updater_->OnIndicatorStatusChanged(
glic_service->is_context_access_indicator_enabled());
}
GlicTabUnderlineView::~GlicTabUnderlineView() = default;
bool GlicTabUnderlineView::IsCycleDone(base::TimeTicks timestamp) {
return progress_ == 1.f;
}
base::TimeDelta GlicTabUnderlineView::GetTotalDuration() const {
return kCycleDuration;
}
void GlicTabUnderlineView::PopulateShaderUniforms(
std::vector<cc::PaintShader::FloatUniform>& float_uniforms,
std::vector<cc::PaintShader::Float2Uniform>& float2_uniforms,
std::vector<cc::PaintShader::Float4Uniform>& float4_uniforms,
std::vector<cc::PaintShader::IntUniform>& int_uniforms) const {
const auto u_resolution = GetLocalBounds();
// Insets aren't relevant to the tab underline effect, but are defined in the
// uniforms of the GlicBorderView shader.
gfx::Insets uniform_insets = gfx::Insets();
float_uniforms.push_back(
{.name = SkString("u_time"), .value = GetEffectTime()});
float_uniforms.push_back(
{.name = SkString("u_emphasis"), .value = emphasis_});
float_uniforms.push_back(
{.name = SkString("u_insets"),
.value = static_cast<float>(uniform_insets.left())});
float_uniforms.push_back(
{.name = SkString("u_progress"), .value = progress_});
float2_uniforms.push_back(
// TODO(https://crbug.com/406026829): Ideally `u_resolution` should be a
// vec4(x, y, w, h) and does not assume the origin is (0, 0). This way we
// can eliminate `u_insets` and void the shader-internal origin-padding.
{.name = SkString("u_resolution"),
.value = SkV2{static_cast<float>(u_resolution.width()),
static_cast<float>(u_resolution.height())}});
int_uniforms.push_back(
{.name = SkString("u_dark"),
.value = theme_service_->BrowserUsesDarkColors() ? 1 : 0});
float4_uniforms.push_back({.name = SkString("u_corner_radius"),
.value = SkV4{kCornerRadius, kCornerRadius,
kCornerRadius, kCornerRadius}});
}
int GlicTabUnderlineView::ComputeWidth() {
// At the smallest tab sizes, favicons can be clipped and so a shorter
// underline is required.
if (size().width() < kMinimumTabWidthThreshold) {
return kMinUnderlineWidth;
}
// Underline should use either the width of the tab's contents bounds or the
// width of the favicon, whichever is greater.
int underline_width = size().width() - tab_->GetInsets().width();
if (underline_width < gfx::kFaviconSize) {
return kSmallUnderlineWidth;
}
return underline_width;
}
void GlicTabUnderlineView::DrawEffect(gfx::Canvas* canvas,
const cc::PaintFlags& flags) {
int underline_width = ComputeWidth();
int underline_x = (size().width() - underline_width + 1) / 2;
// Draw the underline in the bottom `kEffectHeight` area of the given bounds
// below the tab contents.
gfx::Point origin(underline_x, size().height() - kEffectHeight);
gfx::Size size(underline_width, kEffectHeight);
gfx::Rect effect_bounds(origin, size);
cc::PaintFlags new_flags(flags);
// At small sizes, paint the underline as a solid color instead of a gradient.
if (underline_width < gfx::kFaviconSize) {
new_flags.setShader(nullptr);
// `colors_` is not populated if the kGlicParameterizedShader feature is not
// enabled.
if (!colors_.empty()) {
new_flags.setColor(colors_[0]); // -gem-sys-color--brand-blue #3186FF
} else {
// Use -gem-sys-color--brand-blue as fallback color.
const SkColor fallback_color = SkColorSetARGB(255, 49, 134, 255);
new_flags.setColor(fallback_color);
}
}
canvas->DrawRoundRect(gfx::RectF(effect_bounds), kCornerRadius, new_flags);
}
BEGIN_METADATA(GlicTabUnderlineView)
END_METADATA
} // namespace glic