blob: b97a7eb367220197236ad99ac10fee86a7b06770 [file] [log] [blame]
// Copyright 2020 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/ui/views/accessibility/accessibility_focus_highlight.h"
#include <algorithm>
#include "build/build_config.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/color/chrome_color_id.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "chrome/common/pref_names.h"
#include "components/prefs/pref_service.h"
#include "content/public/browser/browser_accessibility_state.h"
#include "content/public/browser/focused_node_details.h"
#include "ui/compositor/compositor.h"
#include "ui/compositor/compositor_animation_observer.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/paint_recorder.h"
#include "ui/display/display.h"
#include "ui/display/screen.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/geometry/vector2d.h"
#include "ui/gfx/geometry/vector2d_conversions.h"
namespace ui {
class Compositor;
}
namespace {
// The number of pixels of padding between the outer edge of the focused
// element's bounding box and the inner edge of the inner focus ring.
constexpr int kPadding = 8;
// The size of the border radius of the innermost focus highlight ring.
constexpr int kBorderRadius = 4;
// The stroke width, in , of the innermost focus ring, and each line drawn
// as part of the focus ring gradient effect.
constexpr int kStrokeWidth = 2;
// The thickness, in px, of the outer focus ring gradient.
constexpr int kGradientWidth = 9;
// The padding between the bounds of the layer and the bounds of the
// drawn focus ring, in px. If it's zero the focus ring might be clipped.
constexpr int kLayerPadding = 2;
// Total px between the edge of the node and the edge of the layer.
constexpr int kTotalLayerPadding =
kPadding + kStrokeWidth + kGradientWidth + kLayerPadding;
// The amount of time it should take for the highlight to fade in.
constexpr auto kFadeInTime = base::Milliseconds(100);
// The amount of time the highlight should persist before beginning to fade.
constexpr auto kHighlightPersistTime = base::Seconds(1);
// The amount of time it should take for the highlight to fade out.
constexpr auto kFadeOutTime = base::Milliseconds(600);
} // namespace
// static
base::TimeDelta AccessibilityFocusHighlight::fade_in_time_;
// static
base::TimeDelta AccessibilityFocusHighlight::persist_time_;
// static
base::TimeDelta AccessibilityFocusHighlight::fade_out_time_;
// static
bool AccessibilityFocusHighlight::skip_activation_check_for_testing_ = false;
// static
bool AccessibilityFocusHighlight::use_default_color_for_testing_ = false;
// static
bool AccessibilityFocusHighlight::no_fade_for_testing_ = false;
AccessibilityFocusHighlight::AccessibilityFocusHighlight(
BrowserView* browser_view)
: browser_view_(browser_view) {
DCHECK(browser_view);
// Listen for preference changes.
profile_pref_registrar_.Init(browser_view_->browser()->profile()->GetPrefs());
profile_pref_registrar_.Add(
prefs::kAccessibilityFocusHighlightEnabled,
base::BindRepeating(&AccessibilityFocusHighlight::AddOrRemoveObservers,
base::Unretained(this)));
// Initialise focus and tab strip model observers based on current
// preferences.
AddOrRemoveObservers();
// One-time initialization of statics the first time an instance is created.
if (fade_in_time_.is_zero()) {
fade_in_time_ = kFadeInTime;
persist_time_ = kHighlightPersistTime;
fade_out_time_ = kFadeOutTime;
}
}
AccessibilityFocusHighlight::~AccessibilityFocusHighlight() {
if (compositor_ && compositor_->HasAnimationObserver(this)) {
compositor_->RemoveAnimationObserver(this);
}
}
// static
void AccessibilityFocusHighlight::SetNoFadeForTesting() {
no_fade_for_testing_ = true;
}
// static
void AccessibilityFocusHighlight::SkipActivationCheckForTesting() {
skip_activation_check_for_testing_ = true;
}
// static
void AccessibilityFocusHighlight::UseDefaultColorForTesting() {
use_default_color_for_testing_ = true;
}
// static
ui::Layer* AccessibilityFocusHighlight::GetLayerForTesting() {
return layer_.get();
}
SkColor AccessibilityFocusHighlight::GetHighlightColor() {
const ui::ColorProvider* color_provider = browser_view_->GetColorProvider();
#if !BUILDFLAG(IS_MAC)
// Match behaviour with renderer_preferences_util::UpdateFromSystemSettings
// setting prefs->focus_ring_color
return color_provider->GetColor(kColorFocusHighlightDefault);
#else
SkColor theme_color =
color_provider->GetColor(ui::kColorFocusableBorderFocused);
if (theme_color == SK_ColorTRANSPARENT || use_default_color_for_testing_) {
return color_provider->GetColor(kColorFocusHighlightDefault);
}
return theme_color;
#endif
}
void AccessibilityFocusHighlight::CreateOrUpdateLayer(gfx::Rect node_bounds) {
// Find the layer of our owning BrowserView.
views::Widget* widget = browser_view_->GetWidget();
DCHECK(widget);
ui::Layer* root_layer = widget->GetLayer();
// Create the layer if needed.
if (!layer_) {
layer_ = std::make_unique<ui::Layer>(ui::LAYER_TEXTURED);
layer_->SetName("AccessibilityFocusHighlight");
layer_->SetFillsBoundsOpaquely(false);
root_layer->Add(layer_.get());
// Initially transparent so it can fade in.
layer_->SetOpacity(0.0f);
layer_->set_delegate(this);
layer_created_time_ = base::TimeTicks::Now();
}
// Each time this is called, move it to the top in case new layers
// have been added since we created this layer.
layer_->parent()->StackAtTop(layer_.get());
// Update the bounds.
// Outset the bounds of the layer by the total width of the focus highlight,
// plus the extra padding to ensure the highlight isn't clipped.
gfx::Rect layer_bounds = node_bounds;
int padding = kTotalLayerPadding;
layer_bounds.Inset(-padding);
layer_->SetBounds(layer_bounds);
// Set node_bounds_ and make their position relative to the layer, instead of
// the page.
node_bounds_ = node_bounds;
node_bounds_.set_x(padding);
node_bounds_.set_y(padding);
// Update the timestamp of the last time the layer changed.
focus_last_changed_time_ = base::TimeTicks::Now();
// Ensure it's repainted.
gfx::Rect bounds(0, 0, layer_bounds.width(), layer_bounds.height());
layer_->SchedulePaint(bounds);
// Schedule the animation observer, or update it if needed.
display::Display display =
display::Screen::Get()->GetDisplayMatching(layer_bounds);
ui::Compositor* compositor = root_layer->GetCompositor();
if (compositor != compositor_) {
if (compositor_ && compositor_->HasAnimationObserver(this)) {
compositor_->RemoveAnimationObserver(this);
}
compositor_ = compositor;
if (compositor_ && !compositor_->HasAnimationObserver(this)) {
compositor_->AddAnimationObserver(this);
}
}
}
void AccessibilityFocusHighlight::RemoveLayer() {
if (no_fade_for_testing_) {
return;
}
layer_.reset();
if (compositor_) {
compositor_->RemoveAnimationObserver(this);
compositor_ = nullptr;
}
}
void AccessibilityFocusHighlight::AddOrRemoveObservers() {
Browser* browser = browser_view_->browser();
PrefService* prefs = browser->profile()->GetPrefs();
TabStripModel* tab_strip_model = browser->tab_strip_model();
if (prefs->GetBoolean(prefs::kAccessibilityFocusHighlightEnabled)) {
// Listen for focus changes. Automatically deregisters when destroyed,
// or when the preference toggles off.
// TODO(crbug.com/40758630): This will fire even for focused-element changes
// in windows other than browser_view_, which might not be ideal behavior.
focus_changed_subscription_ =
content::BrowserAccessibilityState::GetInstance()
->RegisterFocusChangedCallback(base::BindRepeating(
&AccessibilityFocusHighlight::OnFocusChangedInPage,
base::Unretained(this)));
tab_strip_model->AddObserver(this);
return;
} else {
focus_changed_subscription_.reset();
tab_strip_model->RemoveObserver(this);
}
}
void AccessibilityFocusHighlight::OnFocusChangedInPage(
const content::FocusedNodeDetails& details) {
// Unless this is a test, only draw the focus ring if this BrowserView is
// the active one.
// TODO(crbug.com/40758630): Even if this BrowserView is active, it doesn't
// necessarily own the node we're about to highlight.
if (!browser_view_->IsActive() && !skip_activation_check_for_testing_) {
return;
}
// Get the bounds of the focused node from the web page.
gfx::Rect node_bounds = details.node_bounds_in_screen;
// This happens if e.g. we focus on <body>. Don't show a confusing highlight.
if (node_bounds.IsEmpty()) {
return;
}
// Convert it to the local coordinates of this BrowserView's widget.
node_bounds.Offset(-gfx::ToFlooredVector2d(browser_view_->GetWidget()
->GetClientAreaBoundsInScreen()
.OffsetFromOrigin()));
// Create the layer if needed, and move/resize it.
CreateOrUpdateLayer(node_bounds);
}
void AccessibilityFocusHighlight::OnPaintLayer(
const ui::PaintContext& context) {
ui::PaintRecorder recorder(context, layer_->size());
SkColor highlight_color = GetHighlightColor();
cc::PaintFlags original_flags;
original_flags.setAntiAlias(true);
original_flags.setStyle(cc::PaintFlags::kStroke_Style);
original_flags.setColor(highlight_color);
original_flags.setStrokeWidth(kStrokeWidth);
gfx::RectF bounds(node_bounds_);
// Apply padding
bounds.Inset(-kPadding);
// Draw gradient first, so other lines will be drawn over the top.
gfx::RectF gradient_bounds(bounds);
int gradient_border_radius = kBorderRadius;
gradient_bounds.Inset(-kStrokeWidth);
gradient_border_radius += kStrokeWidth;
cc::PaintFlags gradient_flags(original_flags);
gradient_flags.setStrokeWidth(1);
int original_alpha = std::min(SkColorGetA(highlight_color), 192u);
// Create a gradient effect by drawing the path outline multiple
// times with increasing insets from 0 to kGradientWidth, and
// with increasing transparency.
for (int remaining = kGradientWidth; remaining > 0; remaining -= 1) {
// Decrease alpha as distance remaining decreases.
int alpha = (original_alpha * remaining * remaining) /
(kGradientWidth * kGradientWidth);
gradient_flags.setAlphaf(alpha / 255.0f);
recorder.canvas()->DrawRoundRect(gradient_bounds, gradient_border_radius,
gradient_flags);
gradient_bounds.Inset(-1);
gradient_border_radius += 1;
}
// Draw the white ring before the inner ring, so that the inner ring is
// partially over the top, rather than drawing a 1px white ring. A 1px ring
// would be antialiased to look semi-transparent, which is not what we want.
// Resize bounds and border radius around inner ring
gfx::RectF white_ring_bounds(bounds);
white_ring_bounds.Inset(-(kStrokeWidth / 2));
int white_ring_border_radius = kBorderRadius + (kStrokeWidth / 2);
cc::PaintFlags white_ring_flags(original_flags);
white_ring_flags.setColor(SK_ColorWHITE);
recorder.canvas()->DrawRoundRect(white_ring_bounds, white_ring_border_radius,
white_ring_flags);
// Finally, draw the inner ring
recorder.canvas()->DrawRoundRect(bounds, kBorderRadius, original_flags);
}
float AccessibilityFocusHighlight::ComputeOpacity(
base::TimeDelta time_since_layer_create,
base::TimeDelta time_since_focus_move) {
float opacity = 1.0f;
if (no_fade_for_testing_) {
return opacity;
}
if (time_since_layer_create < fade_in_time_) {
// We're fading in.
opacity = time_since_layer_create / fade_in_time_;
}
if (time_since_focus_move > persist_time_) {
// Fading out.
base::TimeDelta time_since_began_fading =
time_since_focus_move - (fade_in_time_ + persist_time_);
opacity = 1.0f - (time_since_began_fading / fade_out_time_);
}
return std::clamp(opacity, 0.0f, 1.0f);
}
void AccessibilityFocusHighlight::OnAnimationStep(base::TimeTicks timestamp) {
if (!layer_) {
return;
}
// It's quite possible for the first 1 or 2 animation frames to be
// for a timestamp that's earlier than the time we received the
// focus change, so we just treat those as a delta of zero.
if (timestamp < layer_created_time_) {
timestamp = layer_created_time_;
}
// The time since the layer was created is used for fading in.
base::TimeDelta time_since_layer_create = timestamp - layer_created_time_;
// For fading out, we look at the time since focus last moved,
// but we adjust it so that this "clock" doesn't start until after
// the first fade in completes.
base::TimeDelta time_since_focus_move =
std::min(timestamp - focus_last_changed_time_,
timestamp - layer_created_time_ - fade_in_time_);
// If the fade out has completed, remove the layer and remove the
// animation observer.
if (time_since_focus_move > persist_time_ + fade_out_time_) {
RemoveLayer();
return;
}
float opacity =
ComputeOpacity(time_since_layer_create, time_since_focus_move);
layer_->SetOpacity(opacity);
}
void AccessibilityFocusHighlight::OnCompositingShuttingDown(
ui::Compositor* compositor) {
DCHECK(compositor);
DCHECK_EQ(compositor, compositor_);
if (compositor == compositor_) {
compositor->RemoveAnimationObserver(this);
compositor_ = nullptr;
}
}
void AccessibilityFocusHighlight::OnTabStripModelChanged(
TabStripModel*,
const TabStripModelChange&,
const TabStripSelectionChange&) {
RemoveLayer();
}