blob: 6f1dbaad4f884f9a7a8935e21ca47f5e1ce21b5c [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// 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/tabs/tab_hover_card_controller.h"
#include "base/bind.h"
#include "base/callback_list.h"
#include "base/feature_list.h"
#include "base/time/time.h"
#include "chrome/browser/metrics/tab_count_metrics.h"
#include "chrome/browser/ui/tabs/tab_style.h"
#include "chrome/browser/ui/ui_features.h"
#include "chrome/browser/ui/views/tabs/tab_hover_card_bubble_view.h"
#include "chrome/browser/ui/views/tabs/tab_hover_card_thumbnail_observer.h"
#include "chrome/browser/ui/views/tabs/tab_strip.h"
#include "third_party/abseil-cpp/absl/types/optional.h"
#include "ui/events/event_observer.h"
#include "ui/events/types/event_type.h"
#include "ui/views/event_monitor.h"
#include "ui/views/view.h"
#include "ui/views/widget/widget.h"
#include "ui/views/widget/widget_observer.h"
namespace {
base::TimeDelta GetPreviewImageCaptureDelay(
ThumbnailImage::CaptureReadiness readiness) {
int ms = 0;
switch (readiness) {
case ThumbnailImage::CaptureReadiness::kNotReady: {
static const int not_ready_delay = base::GetFieldTrialParamByFeatureAsInt(
features::kTabHoverCardImages,
features::kTabHoverCardImagesNotReadyDelayParameterName, 0);
ms = not_ready_delay;
break;
}
case ThumbnailImage::CaptureReadiness::kReadyForInitialCapture: {
static const int loading_delay = base::GetFieldTrialParamByFeatureAsInt(
features::kTabHoverCardImages,
features::kTabHoverCardImagesLoadingDelayParameterName, 0);
ms = loading_delay;
break;
}
case ThumbnailImage::CaptureReadiness::kReadyForFinalCapture: {
static const int loaded_delay = base::GetFieldTrialParamByFeatureAsInt(
features::kTabHoverCardImages,
features::kTabHoverCardImagesLoadedDelayParameterName, 0);
ms = loaded_delay;
break;
}
}
DCHECK_GE(ms, 0);
return base::TimeDelta::FromMilliseconds(ms);
}
base::TimeDelta GetShowDelay(int tab_width) {
static const int max_width_additiona_delay =
base::GetFieldTrialParamByFeatureAsInt(
features::kTabHoverCardImages,
features::kTabHoverCardAdditionalMaxWidthDelay, 0);
// Delay is calculated as a logarithmic scale and bounded by a minimum width
// based on the width of a pinned tab and a maximum of the standard width.
//
// delay (ms)
// |
// max delay-| *
// | *
// | *
// | *
// | *
// | *
// | *
// | *
// | *
// min delay-|****
// |___________________________________________ tab width
// | |
// pinned tab width standard tab width
constexpr base::TimeDelta kMinimumTriggerDelay =
base::TimeDelta::FromMilliseconds(300);
if (tab_width < TabStyle::GetPinnedWidth())
return kMinimumTriggerDelay;
constexpr base::TimeDelta kMaximumTriggerDelay =
base::TimeDelta::FromMilliseconds(800);
double logarithmic_fraction =
std::log(tab_width - TabStyle::GetPinnedWidth() + 1) /
std::log(TabStyle::GetStandardWidth() - TabStyle::GetPinnedWidth() + 1);
base::TimeDelta scaling_factor = kMaximumTriggerDelay - kMinimumTriggerDelay;
base::TimeDelta delay =
logarithmic_fraction * scaling_factor + kMinimumTriggerDelay;
if (tab_width >= TabStyle::GetStandardWidth())
delay += base::TimeDelta::FromMilliseconds(max_width_additiona_delay);
return delay;
}
} // anonymous namespace
//-------------------------------------------------------------------
// TabHoverCardController::EventSniffer
// Listens in on the browser event stream and hides an associated hover card
// on any keypress, mouse click, or gesture.
class TabHoverCardController::EventSniffer : public ui::EventObserver {
public:
explicit EventSniffer(TabHoverCardController* controller)
: controller_(controller) {
// Note that null is a valid value for the second parameter here; if for
// some reason there is no native window it simply falls back to
// application-wide event-sniffing, which for this case is better than not
// watching events at all.
event_monitor_ = views::EventMonitor::CreateWindowMonitor(
this, controller_->tab_strip_->GetWidget()->GetNativeWindow(),
{ui::ET_KEY_PRESSED, ui::ET_KEY_RELEASED, ui::ET_MOUSE_PRESSED,
ui::ET_MOUSE_RELEASED, ui::ET_GESTURE_BEGIN, ui::ET_GESTURE_END});
}
~EventSniffer() override = default;
protected:
// ui::EventObserver:
void OnEvent(const ui::Event& event) override {
bool close_hover_card = true;
if (event.IsKeyEvent()) {
// Hover card needs to be dismissed (and regenerated) if the keypress
// would select the tab (this also takes focus out of the tabstrip).
close_hover_card = event.AsKeyEvent()->key_code() == ui::VKEY_RETURN ||
event.AsKeyEvent()->key_code() == ui::VKEY_ESCAPE ||
!controller_->tab_strip_->IsFocusInTabs();
}
if (close_hover_card) {
controller_->UpdateHoverCard(nullptr,
TabController::HoverCardUpdateType::kEvent);
}
}
private:
TabHoverCardController* const controller_;
std::unique_ptr<views::EventMonitor> event_monitor_;
};
//-------------------------------------------------------------------
// TabHoverCardController
// static
bool TabHoverCardController::disable_animations_for_testing_ = false;
TabHoverCardController::TabHoverCardController(TabStrip* tab_strip)
: tab_strip_(tab_strip),
metrics_(std::make_unique<TabHoverCardMetrics>(this)) {}
TabHoverCardController::~TabHoverCardController() = default;
// static
bool TabHoverCardController::AreHoverCardImagesEnabled() {
return base::FeatureList::IsEnabled(features::kTabHoverCardImages);
}
bool TabHoverCardController::IsHoverCardVisible() const {
return hover_card_ != nullptr && hover_card_->GetWidget() &&
!hover_card_->GetWidget()->IsClosed();
}
bool TabHoverCardController::IsHoverCardShowingForTab(Tab* tab) const {
return IsHoverCardVisible() && !fade_animator_->IsFadingOut() &&
GetTargetAnchorView() == tab;
}
void TabHoverCardController::UpdateHoverCard(
Tab* tab,
TabController::HoverCardUpdateType update_type) {
// Never display a hover card for a closing tab.
if (tab && tab->closing())
tab = nullptr;
// Update this ASAP so that if we try to fade-in and we have the wrong target
// then when the fade timer elapses we won't incorrectly try to fade in on the
// wrong tab.
if (target_tab_ != tab) {
target_tab_observation_.Reset();
if (tab)
target_tab_observation_.Observe(tab);
target_tab_ = tab;
delayed_show_timer_.Stop();
}
// If there's nothing to attach to then there's no point in creating a card.
if (!hover_card_ && (!tab || !tab_strip_->GetWidget()))
return;
switch (update_type) {
case TabController::HoverCardUpdateType::kSelectionChanged:
metrics_->TabSelectionChanged();
break;
case TabController::HoverCardUpdateType::kHover:
if (!tab)
last_mouse_exit_timestamp_ = base::TimeTicks::Now();
break;
case TabController::HoverCardUpdateType::kTabDataChanged:
DCHECK(tab && IsHoverCardShowingForTab(tab));
break;
case TabController::HoverCardUpdateType::kTabRemoved:
case TabController::HoverCardUpdateType::kAnimating:
// Neither of these cases should have a tab associated.
DCHECK(!tab);
break;
case TabController::HoverCardUpdateType::kEvent:
case TabController::HoverCardUpdateType::kFocus:
// No special action taken for this type of even (yet).
break;
}
if (tab)
UpdateOrShowCard(tab, update_type);
else
HideHoverCard();
}
void TabHoverCardController::PreventImmediateReshow() {
last_mouse_exit_timestamp_ = base::TimeTicks();
}
void TabHoverCardController::TabSelectedViaMouse(Tab* tab) {
metrics_->TabSelectedViaMouse(tab);
}
void TabHoverCardController::UpdateOrShowCard(
Tab* tab,
TabController::HoverCardUpdateType update_type) {
// Close is asynchronous, so make sure that if we're closing we clear out all
// of our data *now* rather than waiting for the deletion message.
if (hover_card_ && hover_card_->GetWidget()->IsClosed())
OnViewIsDeleting(hover_card_);
// If a hover card is being updated because of a data change, the hover card
// had better already be showing for the affected tab.
if (update_type == TabController::HoverCardUpdateType::kTabDataChanged) {
DCHECK(IsHoverCardShowingForTab(tab));
UpdateCardContent(tab);
slide_animator_->UpdateTargetBounds();
return;
}
// Cancel any pending fades.
if (hover_card_ && fade_animator_->IsFadingOut()) {
fade_animator_->CancelFadeOut();
metrics_->CardFadeCanceled();
}
if (hover_card_) {
// Card should never exist without an anchor.
DCHECK(hover_card_->GetAnchorView());
// If the card was visible we need to update the card now, before any slide
// or snap occurs.
UpdateCardContent(tab);
MaybeStartThumbnailObservation(tab, /* is_initial_show */ false);
// If widget is already visible and anchored to the correct tab we should
// not try to reset the anchor view or reshow.
if (!UseAnimations() || (hover_card_->GetAnchorView() == tab &&
!slide_animator_->is_animating())) {
slide_animator_->SnapToAnchorView(tab);
} else {
slide_animator_->AnimateToAnchorView(tab);
}
return;
}
// Maybe make hover card visible. Disabling animations for testing also
// eliminates the show timer, lest the tests have to be significantly more
// complex and time-consuming.
const bool is_initial = !ShouldShowImmediately(tab);
if (is_initial)
metrics_->InitialCardBeingShown();
if (is_initial && !disable_animations_for_testing_) {
delayed_show_timer_.Start(
FROM_HERE, GetShowDelay(tab->width()),
base::BindOnce(&TabHoverCardController::ShowHoverCard,
base::Unretained(this), true, tab));
} else {
DCHECK_EQ(target_tab_, tab);
ShowHoverCard(is_initial, tab);
}
}
void TabHoverCardController::ShowHoverCard(bool is_initial,
const Tab* intended_tab) {
// Make sure the hover card isn't accidentally shown if it's already visible
// or if the anchor is gone or changed.
if (hover_card_ || !target_tab_ || target_tab_ != intended_tab)
return;
CreateHoverCard(target_tab_);
UpdateCardContent(target_tab_);
slide_animator_->UpdateTargetBounds();
MaybeStartThumbnailObservation(target_tab_, is_initial);
// Ensure the hover card Widget assumes the highest z-order to avoid occlusion
// by other secondary UI Widgets (such as the omnibox Widget, see
// crbug.com/1226536).
hover_card_->GetWidget()->StackAtTop();
if (!is_initial || !UseAnimations()) {
OnCardFullyVisible();
hover_card_->GetWidget()->Show();
return;
}
metrics_->CardFadingIn();
fade_animator_->FadeIn();
}
void TabHoverCardController::HideHoverCard() {
if (!hover_card_ || hover_card_->GetWidget()->IsClosed())
return;
if (thumbnail_observer_) {
thumbnail_observer_->Observe(nullptr);
thumbnail_wait_state_ = ThumbnailWaitState::kNotWaiting;
}
// This needs to be called whether we're doing a fade or a pop out.
metrics_->CardWillBeHidden();
slide_animator_->StopAnimation();
if (!UseAnimations()) {
hover_card_->GetWidget()->Close();
return;
}
if (fade_animator_->IsFadingOut())
return;
metrics_->CardFadingOut();
fade_animator_->FadeOut();
}
// static
bool TabHoverCardController::UseAnimations() {
return !disable_animations_for_testing_ &&
gfx::Animation::ShouldRenderRichAnimation();
}
void TabHoverCardController::OnViewIsDeleting(views::View* observed_view) {
if (hover_card_ == observed_view) {
hover_card_observation_.Reset();
event_sniffer_.reset();
slide_progressed_subscription_ = base::CallbackListSubscription();
slide_complete_subscription_ = base::CallbackListSubscription();
fade_complete_subscription_ = base::CallbackListSubscription();
slide_animator_.reset();
fade_animator_.reset();
hover_card_ = nullptr;
} else if (target_tab_ == observed_view) {
UpdateHoverCard(nullptr, TabController::HoverCardUpdateType::kTabRemoved);
// These postconditions should always be met after calling
// UpdateHoverCard(nullptr, ...)
DCHECK(!target_tab_);
DCHECK(!target_tab_observation_.IsObserving());
}
}
size_t TabHoverCardController::GetTabCount() const {
return tab_count_metrics::TabCount();
}
bool TabHoverCardController::ArePreviewsEnabled() const {
return static_cast<bool>(thumbnail_observer_);
}
views::Widget* TabHoverCardController::GetHoverCardWidget() {
return hover_card_ ? hover_card_->GetWidget() : nullptr;
}
void TabHoverCardController::CreateHoverCard(Tab* tab) {
hover_card_ = new TabHoverCardBubbleView(tab);
hover_card_observation_.Observe(hover_card_);
event_sniffer_ = std::make_unique<EventSniffer>(this);
slide_animator_ = std::make_unique<views::BubbleSlideAnimator>(hover_card_);
slide_animator_->SetSlideDuration(
TabHoverCardBubbleView::kHoverCardSlideDuration);
slide_progressed_subscription_ = slide_animator_->AddSlideProgressedCallback(
base::BindRepeating(&TabHoverCardController::OnSlideAnimationProgressed,
base::Unretained(this)));
slide_complete_subscription_ = slide_animator_->AddSlideCompleteCallback(
base::BindRepeating(&TabHoverCardController::OnSlideAnimationComplete,
base::Unretained(this)));
fade_animator_ =
std::make_unique<views::WidgetFadeAnimator>(hover_card_->GetWidget());
fade_complete_subscription_ = fade_animator_->AddFadeCompleteCallback(
base::BindRepeating(&TabHoverCardController::OnFadeAnimationEnded,
base::Unretained(this)));
if (!thumbnail_observer_ && AreHoverCardImagesEnabled()) {
thumbnail_observer_ = std::make_unique<TabHoverCardThumbnailObserver>();
thumbnail_subscription_ = thumbnail_observer_->AddCallback(
base::BindRepeating(&TabHoverCardController::OnPreviewImageAvaialble,
base::Unretained(this)));
}
}
void TabHoverCardController::UpdateCardContent(Tab* tab) {
// If the hover card is transitioning between tabs, we need to do a
// cross-fade.
if (hover_card_->GetAnchorView() != tab)
hover_card_->SetTextFade(0.0);
hover_card_->UpdateCardContent(tab);
}
void TabHoverCardController::MaybeStartThumbnailObservation(
Tab* tab,
bool is_initial_show) {
// If the preview image feature is not enabled, |thumbnail_observer_| will be
// null.
if (!thumbnail_observer_)
return;
// Active tabs don't get thumbnails.
if (tab->IsActive()) {
thumbnail_observer_->Observe(nullptr);
return;
}
auto thumbnail = tab->data().thumbnail;
if (!thumbnail) {
hover_card_->SetPlaceholderImage();
thumbnail_wait_state_ = ThumbnailWaitState::kNotWaiting;
return;
}
if (thumbnail == thumbnail_observer_->current_image())
return;
// We're definitely going to wait for an image at some point.
const auto crossfade_at =
TabHoverCardBubbleView::GetPreviewImageCrossfadeStart();
if (crossfade_at.has_value() && crossfade_at.value() == 0.0) {
hover_card_->SetPlaceholderImage();
thumbnail_wait_state_ = ThumbnailWaitState::kWaitingWithPlaceholder;
} else {
thumbnail_wait_state_ = ThumbnailWaitState::kWaitingWithoutPlaceholder;
}
// For the first show there has already been a delay, so it's fine to ask for
// the image immediately; same is true if we already have a thumbnail.
// Otherwise the delay is based on the capture readiness.
const base::TimeDelta capture_delay =
is_initial_show || thumbnail->has_data()
? base::TimeDelta()
: GetPreviewImageCaptureDelay(thumbnail->GetCaptureReadiness());
if (capture_delay.is_zero()) {
thumbnail_observer_->Observe(thumbnail);
} else if (!delayed_show_timer_.IsRunning()) {
// Stop updating the preview image unless/until we re-enable capture.
thumbnail_observer_->Observe(nullptr);
if (thumbnail_wait_state_ ==
ThumbnailWaitState::kWaitingWithoutPlaceholder) {
hover_card_->SetPlaceholderImage();
thumbnail_wait_state_ = ThumbnailWaitState::kWaitingWithPlaceholder;
}
delayed_show_timer_.Start(
FROM_HERE, capture_delay,
base::BindOnce(&TabHoverCardController::StartThumbnailObservation,
base::Unretained(this), tab));
}
}
void TabHoverCardController::StartThumbnailObservation(Tab* tab) {
if (tab != target_tab_)
return;
DCHECK(tab);
DCHECK(hover_card_);
DCHECK(waiting_for_preview());
auto thumbnail = tab->data().thumbnail;
if (!thumbnail || thumbnail == thumbnail_observer_->current_image())
return;
thumbnail_observer_->Observe(thumbnail);
}
bool TabHoverCardController::ShouldShowImmediately(const Tab* tab) const {
// If less than |kShowWithoutDelayTimeBuffer| time has passed since the hover
// card was last visible then it is shown immediately. This is to account for
// if hover unintentionally leaves the tab strip.
constexpr base::TimeDelta kShowWithoutDelayTimeBuffer =
base::TimeDelta::FromMilliseconds(300);
base::TimeDelta elapsed_time =
base::TimeTicks::Now() - last_mouse_exit_timestamp_;
bool within_delay_time_buffer = !last_mouse_exit_timestamp_.is_null() &&
elapsed_time <= kShowWithoutDelayTimeBuffer;
// Hover cards should be shown without delay if triggered within the time
// buffer or if the tab or its children have focus which indicates that the
// tab is keyboard focused.
const views::FocusManager* const tab_focus_manager = tab->GetFocusManager();
return within_delay_time_buffer || tab->HasFocus() ||
(tab_focus_manager &&
tab->Contains(tab_focus_manager->GetFocusedView()));
}
const views::View* TabHoverCardController::GetTargetAnchorView() const {
if (!hover_card_)
return nullptr;
if (slide_animator_->is_animating())
return slide_animator_->desired_anchor_view();
return hover_card_->GetAnchorView();
}
void TabHoverCardController::OnCardFullyVisible() {
const bool has_preview = ArePreviewsEnabled() && !target_tab_->IsActive() &&
!waiting_for_preview();
metrics_->CardFullyVisibleOnTab(target_tab_, has_preview);
}
void TabHoverCardController::OnFadeAnimationEnded(
views::WidgetFadeAnimator* animator,
views::WidgetFadeAnimator::FadeType fade_type) {
// There's a potential race condition where we get the fade in complete signal
// just as we've decided to fade out, so check for null.
// See: crbug.com/1192451
if (target_tab_ && fade_type == views::WidgetFadeAnimator::FadeType::kFadeIn)
OnCardFullyVisible();
metrics_->CardFadeComplete();
if (fade_type == views::WidgetFadeAnimator::FadeType::kFadeOut)
hover_card_->GetWidget()->Close();
}
void TabHoverCardController::OnSlideAnimationProgressed(
views::BubbleSlideAnimator* animator,
double value) {
if (hover_card_)
hover_card_->SetTextFade(value);
if (thumbnail_wait_state_ == ThumbnailWaitState::kWaitingWithoutPlaceholder) {
const auto crossfade_start =
TabHoverCardBubbleView::GetPreviewImageCrossfadeStart();
if (crossfade_start.has_value() && value >= crossfade_start.value()) {
hover_card_->SetPlaceholderImage();
thumbnail_wait_state_ = ThumbnailWaitState::kWaitingWithPlaceholder;
}
}
}
void TabHoverCardController::OnSlideAnimationComplete(
views::BubbleSlideAnimator* animator) {
// Make sure we're displaying the new text at 100% opacity, and none of the
// old text.
hover_card_->SetTextFade(1.0);
// If we were waiting for a preview image with data to load, we don't want to
// keep showing the old image while hovering on the new tab, so clear it. This
// shouldn't happen very often for slide animations, but could on slower
// computers.
if (thumbnail_wait_state_ == ThumbnailWaitState::kWaitingWithoutPlaceholder) {
hover_card_->SetPlaceholderImage();
thumbnail_wait_state_ = ThumbnailWaitState::kWaitingWithPlaceholder;
}
OnCardFullyVisible();
}
void TabHoverCardController::OnPreviewImageAvaialble(
TabHoverCardThumbnailObserver* observer,
gfx::ImageSkia thumbnail_image) {
DCHECK_EQ(thumbnail_observer_.get(), observer);
const bool was_waiting_for_preview = waiting_for_preview();
thumbnail_wait_state_ = ThumbnailWaitState::kNotWaiting;
// The hover card could be destroyed before the preview image is delivered.
if (!hover_card_)
return;
if (was_waiting_for_preview && target_tab_)
metrics_->ImageLoadedForTab(target_tab_);
// Can still set image on a fading-out hover card (we can change this behavior
// later if we want).
hover_card_->SetTargetTabImage(thumbnail_image);
}