blob: 646939ced3226b07b72bae2437fd546f13e23ae0 [file] [log] [blame]
// Copyright 2019 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_group_header.h"
#include <memory>
#include <utility>
#include "base/feature_list.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "build/build_config.h"
#include "chrome/browser/ui/layout_constants.h"
#include "chrome/browser/ui/tabs/tab_style.h"
#include "chrome/browser/ui/ui_features.h"
#include "chrome/browser/ui/views/tabs/tab_controller.h"
#include "chrome/browser/ui/views/tabs/tab_group_editor_bubble_view.h"
#include "chrome/browser/ui/views/tabs/tab_group_underline.h"
#include "chrome/browser/ui/views/tabs/tab_slot_view.h"
#include "chrome/browser/ui/views/tabs/tab_strip.h"
#include "chrome/browser/ui/views/tabs/tab_strip_controller.h"
#include "chrome/browser/ui/views/tabs/tab_strip_layout.h"
#include "chrome/browser/ui/views/tabs/tab_strip_types.h"
#include "chrome/grit/generated_resources.h"
#include "components/tab_groups/tab_group_color.h"
#include "components/tab_groups/tab_group_id.h"
#include "components/tab_groups/tab_group_visual_data.h"
#include "third_party/skia/include/core/SkColor.h"
#include "third_party/skia/include/core/SkPath.h"
#include "ui/accessibility/ax_node_data.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/color_utils.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/skia_util.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/focus_ring.h"
#include "ui/views/controls/highlight_path_generator.h"
#include "ui/views/controls/label.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/flex_layout_types.h"
#include "ui/views/layout/layout_provider.h"
#include "ui/views/view.h"
#include "ui/views/view_class_properties.h"
namespace {
constexpr int kEmptyChipSize = 14;
int GetChipCornerRadius() {
return TabStyle::GetCornerRadius() - TabGroupUnderline::kStrokeThickness;
}
class TabGroupHighlightPathGenerator : public views::HighlightPathGenerator {
public:
TabGroupHighlightPathGenerator(const views::View* chip,
const views::View* title)
: chip_(chip), title_(title) {}
TabGroupHighlightPathGenerator(const TabGroupHighlightPathGenerator&) =
delete;
TabGroupHighlightPathGenerator& operator=(
const TabGroupHighlightPathGenerator&) = delete;
// views::HighlightPathGenerator:
SkPath GetHighlightPath(const views::View* view) override {
SkScalar corner_radius =
title_->GetVisible() ? GetChipCornerRadius() : kEmptyChipSize / 2;
return SkPath().addRoundRect(gfx::RectToSkRect(chip_->bounds()),
corner_radius, corner_radius);
}
private:
const views::View* const chip_;
const views::View* const title_;
};
} // namespace
TabGroupHeader::TabGroupHeader(TabStrip* tab_strip,
const tab_groups::TabGroupId& group)
: tab_strip_(tab_strip) {
DCHECK(tab_strip);
set_group(group);
set_context_menu_controller(this);
// The size and color of the chip are set in VisualsChanged().
title_chip_ = AddChildView(std::make_unique<views::View>());
// Disable events processing (like tooltip handling)
// for children of TabGroupHeader.
title_chip_->SetCanProcessEventsWithinSubtree(false);
// The text and color of the title are set in VisualsChanged().
title_ = title_chip_->AddChildView(std::make_unique<views::Label>());
title_->SetCollapseWhenHidden(true);
title_->SetAutoColorReadabilityEnabled(false);
title_->SetHorizontalAlignment(gfx::ALIGN_LEFT);
title_->SetElideBehavior(gfx::FADE_TAIL);
// Enable keyboard focus.
SetFocusBehavior(FocusBehavior::ACCESSIBLE_ONLY);
views::FocusRing::Install(this);
views::HighlightPathGenerator::Install(
this,
std::make_unique<TabGroupHighlightPathGenerator>(title_chip_, title_));
SetEventTargeter(std::make_unique<views::ViewTargeter>(this));
last_modified_expansion_ = base::TimeTicks::Now();
}
TabGroupHeader::~TabGroupHeader() {
LogCollapseTime();
}
bool TabGroupHeader::OnKeyPressed(const ui::KeyEvent& event) {
if ((event.key_code() == ui::VKEY_SPACE ||
event.key_code() == ui::VKEY_RETURN) &&
!editor_bubble_tracker_.is_open()) {
bool successful_toggle =
tab_strip_->controller()->ToggleTabGroupCollapsedState(
group().value(), ToggleTabGroupCollapsedStateOrigin::kKeyboard);
if (successful_toggle) {
#if defined(OS_WIN)
NotifyAccessibilityEvent(ax::mojom::Event::kSelection, true);
#else
NotifyAccessibilityEvent(ax::mojom::Event::kAlert, true);
#endif
LogCollapseTime();
}
return true;
}
constexpr int kModifiedFlag =
#if defined(OS_MAC)
ui::EF_COMMAND_DOWN;
#else
ui::EF_CONTROL_DOWN;
#endif
if (event.type() == ui::ET_KEY_PRESSED && (event.flags() & kModifiedFlag)) {
if (event.key_code() == ui::VKEY_RIGHT) {
tab_strip_->ShiftGroupRight(group().value());
return true;
}
if (event.key_code() == ui::VKEY_LEFT) {
tab_strip_->ShiftGroupLeft(group().value());
return true;
}
}
return false;
}
bool TabGroupHeader::OnMousePressed(const ui::MouseEvent& event) {
// Ignore the click if the editor is already open. Do this so clicking
// on us again doesn't re-trigger the editor.
//
// Though the bubble is deactivated before we receive a mouse event,
// the actual widget destruction happens in a posted task. That task
// gets run after we receive the mouse event. If this sounds brittle,
// that's because it is!
if (editor_bubble_tracker_.is_open())
return false;
// Allow a right click from touch to drag, which corresponds to a long click.
if (event.IsOnlyLeftMouseButton() ||
(event.IsOnlyRightMouseButton() && event.flags() & ui::EF_FROM_TOUCH)) {
tab_strip_->MaybeStartDrag(this, event, tab_strip_->GetSelectionModel());
return true;
}
return false;
}
bool TabGroupHeader::OnMouseDragged(const ui::MouseEvent& event) {
tab_strip_->ContinueDrag(this, event);
return true;
}
void TabGroupHeader::OnMouseReleased(const ui::MouseEvent& event) {
if (!dragging()) {
if (event.IsLeftMouseButton()) {
bool successful_toggle =
tab_strip_->controller()->ToggleTabGroupCollapsedState(
group().value(), ToggleTabGroupCollapsedStateOrigin::kMouse);
if (successful_toggle)
LogCollapseTime();
} else if (event.IsRightMouseButton() &&
!editor_bubble_tracker_.is_open()) {
editor_bubble_tracker_.Opened(TabGroupEditorBubbleView::Show(
tab_strip_->controller()->GetBrowser(), group().value(), this));
}
}
tab_strip_->EndDrag(END_DRAG_COMPLETE);
}
void TabGroupHeader::OnMouseEntered(const ui::MouseEvent& event) {
// Hide the hover card, since there currently isn't anything to display
// for a group.
tab_strip_->UpdateHoverCard(nullptr,
TabController::HoverCardUpdateType::kHover);
}
void TabGroupHeader::OnThemeChanged() {
TabSlotView::OnThemeChanged();
VisualsChanged();
}
void TabGroupHeader::OnGestureEvent(ui::GestureEvent* event) {
tab_strip_->UpdateHoverCard(nullptr,
TabController::HoverCardUpdateType::kEvent);
switch (event->type()) {
case ui::ET_GESTURE_TAP: {
bool successful_toggle =
tab_strip_->controller()->ToggleTabGroupCollapsedState(
group().value(), ToggleTabGroupCollapsedStateOrigin::kGesture);
if (successful_toggle)
LogCollapseTime();
break;
}
case ui::ET_GESTURE_LONG_TAP: {
editor_bubble_tracker_.Opened(TabGroupEditorBubbleView::Show(
tab_strip_->controller()->GetBrowser(), group().value(), this));
break;
}
case ui::ET_GESTURE_SCROLL_BEGIN: {
tab_strip_->MaybeStartDrag(this, *event, tab_strip_->GetSelectionModel());
break;
}
default:
break;
}
event->SetHandled();
}
void TabGroupHeader::OnFocus() {
View::OnFocus();
tab_strip_->UpdateHoverCard(nullptr,
TabController::HoverCardUpdateType::kFocus);
}
void TabGroupHeader::GetAccessibleNodeData(ui::AXNodeData* node_data) {
node_data->role = ax::mojom::Role::kTabList;
node_data->AddState(ax::mojom::State::kEditable);
bool is_collapsed =
tab_strip_->controller()->IsGroupCollapsed(group().value());
if (is_collapsed) {
node_data->AddState(ax::mojom::State::kCollapsed);
node_data->RemoveState(ax::mojom::State::kExpanded);
} else {
node_data->AddState(ax::mojom::State::kExpanded);
node_data->RemoveState(ax::mojom::State::kCollapsed);
}
std::u16string title =
tab_strip_->controller()->GetGroupTitle(group().value());
std::u16string contents =
tab_strip_->controller()->GetGroupContentString(group().value());
std::u16string collapsed_state = std::u16string();
// Windows screen reader properly announces the state set above in |node_data|
// and will read out the state change when the header's collapsed state is
// toggled. The state is added into the title for other platforms and the title
// will be reread with the updated state when the header's collapsed state is
// toggled.
#if !defined(OS_WIN)
collapsed_state =
is_collapsed ? l10n_util::GetStringUTF16(IDS_GROUP_AX_LABEL_COLLAPSED)
: l10n_util::GetStringUTF16(IDS_GROUP_AX_LABEL_EXPANDED);
#endif
if (title.empty()) {
node_data->SetName(l10n_util::GetStringFUTF16(
IDS_GROUP_AX_LABEL_UNNAMED_GROUP_FORMAT, contents, collapsed_state));
} else {
node_data->SetName(
l10n_util::GetStringFUTF16(IDS_GROUP_AX_LABEL_NAMED_GROUP_FORMAT, title,
contents, collapsed_state));
}
}
std::u16string TabGroupHeader::GetTooltipText(const gfx::Point& p) const {
if (!title_->GetText().empty()) {
return l10n_util::GetStringFUTF16(
IDS_TAB_GROUPS_NAMED_GROUP_TOOLTIP, title_->GetText(),
tab_strip_->controller()->GetGroupContentString(group().value()));
} else {
return l10n_util::GetStringFUTF16(
IDS_TAB_GROUPS_UNNAMED_GROUP_TOOLTIP,
tab_strip_->controller()->GetGroupContentString(group().value()));
}
}
TabSlotView::ViewType TabGroupHeader::GetTabSlotViewType() const {
return TabSlotView::ViewType::kTabGroupHeader;
}
TabSizeInfo TabGroupHeader::GetTabSizeInfo() const {
TabSizeInfo size_info;
// Group headers have a fixed width based on |title_|'s width.
const int width = GetDesiredWidth();
size_info.pinned_tab_width = width;
size_info.min_active_width = width;
size_info.min_inactive_width = width;
size_info.standard_width = width;
return size_info;
}
void TabGroupHeader::ShowContextMenuForViewImpl(
views::View* source,
const gfx::Point& point,
ui::MenuSourceType source_type) {
if (editor_bubble_tracker_.is_open())
return;
// When the context menu is triggered via keyboard, the keyboard event
// propagates to the textfield inside the Editor Bubble. In those cases, we
// want to tell the Editor Bubble to stop the event by setting
// stop_context_menu_propagation to true.
//
// However, when the context menu is triggered via mouse, the same event
// sequence doesn't happen. Stopping the context menu propagation in that case
// would artificially hide the textfield's context menu the first time the
// user tried to access it. So we don't want to stop the context menu
// propagation if this call is reached via mouse.
//
// Notably, event behavior with a mouse is inconsistent depending on
// OS. When not on Mac, the OnMouseReleased() event happens first and opens
// the Editor Bubble early, preempting the Show() call below. On Mac, the
// ShowContextMenu() event happens first and the Show() call is made here.
//
// So, because of the event order on non-Mac, and because there is no native
// way to open a context menu via keyboard on Mac, we assume that we've
// reached this function via mouse if and only if the current OS is Mac.
// Therefore, we don't stop the menu propagation in that case.
constexpr bool kStopContextMenuPropagation =
#if defined(OS_MAC)
false;
#else
true;
#endif
editor_bubble_tracker_.Opened(TabGroupEditorBubbleView::Show(
tab_strip_->controller()->GetBrowser(), group().value(), this,
absl::nullopt, nullptr, kStopContextMenuPropagation));
}
bool TabGroupHeader::DoesIntersectRect(const views::View* target,
const gfx::Rect& rect) const {
// Tab group headers are only highlighted with a tab shape while dragging, so
// visually the header is basically a rectangle between two tab separators.
// The distance from the endge of the view to the tab separator is half of the
// overlap distance. We should only accept events between the separators.
gfx::Rect contents_rect = GetLocalBounds();
contents_rect.Inset(TabStyle::GetTabOverlap() / 2, 0);
return contents_rect.Intersects(rect);
}
int TabGroupHeader::GetDesiredWidth() const {
// If the tab group is collapsed, we want the right margin of the title to
// match the left margin. The left margin is always the group stroke inset.
// Using these values also guarantees the chip aligns with the collapsed
// stroke.
if (tab_strip_->controller()->IsGroupCollapsed(group().value()))
return title_chip_->width() + 2 * TabGroupUnderline::GetStrokeInset();
// We don't want tabs to visually overlap group headers, so we add that space
// to the width to compensate. We don't want to actually remove the overlap
// during layout however; that would cause an the margin to be visually uneven
// when the header is in the first slot and thus wouldn't overlap anything to
// the left.
const int overlap_margin = TabStyle::GetTabOverlap() * 2;
// The empty and non-empty chips have different sizes and corner radii, but
// both should look nestled against the group stroke of the tab to the right.
// This requires a +/- 2px adjustment to the width, which causes the tab to
// the right to be positioned in the right spot.
const std::u16string title =
tab_strip_->controller()->GetGroupTitle(group().value());
const int right_adjust = title.empty() ? 2 : -2;
return overlap_margin + title_chip_->width() + right_adjust;
}
void TabGroupHeader::LogCollapseTime() {
base::TimeTicks current_time = base::TimeTicks::Now();
const int kMinSample = 1;
const int kMaxSample = 86400;
const int kBucketCount = 50;
base::TimeDelta time_delta = current_time - last_modified_expansion_;
if (tab_strip_->controller()->IsGroupCollapsed(group().value())) {
base::UmaHistogramCustomCounts("TabGroups.TimeSpentExpanded2",
time_delta.InSeconds(), kMinSample,
kMaxSample, kBucketCount);
} else {
base::UmaHistogramCustomCounts("TabGroups.TimeSpentCollapsed2",
time_delta.InSeconds(), kMinSample,
kMaxSample, kBucketCount);
}
last_modified_expansion_ = current_time;
}
void TabGroupHeader::VisualsChanged() {
const std::u16string title =
tab_strip_->controller()->GetGroupTitle(group().value());
const tab_groups::TabGroupColorId color_id =
tab_strip_->controller()->GetGroupColorId(group().value());
const SkColor color = tab_strip_->GetPaintedGroupColor(color_id);
title_->SetText(title);
if (title.empty()) {
// If the title is empty, the chip is just a circle.
const int y = (GetLayoutConstant(TAB_HEIGHT) - kEmptyChipSize) / 2;
title_chip_->SetBounds(TabGroupUnderline::GetStrokeInset(), y,
kEmptyChipSize, kEmptyChipSize);
title_chip_->SetBackground(
views::CreateRoundedRectBackground(color, kEmptyChipSize / 2));
} else {
// If the title is set, the chip is a rounded rect that matches the active
// tab shape, particularly the tab's corner radius.
title_->SetEnabledColor(color_utils::GetColorWithMaxContrast(color));
// Set the radius such that the chip nestles snugly against the tab corner
// radius, taking into account the group underline stroke.
const int corner_radius = GetChipCornerRadius();
// Clamp the width to a maximum of half the standard tab width (not counting
// overlap).
const int max_width =
(TabStyle::GetStandardWidth() - TabStyle::GetTabOverlap()) / 2;
const int text_width =
std::min(title_->GetPreferredSize().width(), max_width);
const int text_height = title_->GetPreferredSize().height();
const int text_vertical_inset = 1;
const int text_horizontal_inset = corner_radius + text_vertical_inset;
const int y =
(GetLayoutConstant(TAB_HEIGHT) - text_height) / 2 - text_vertical_inset;
title_chip_->SetBounds(TabGroupUnderline::GetStrokeInset(), y,
text_width + 2 * text_horizontal_inset,
text_height + 2 * text_vertical_inset);
title_chip_->SetBackground(
views::CreateRoundedRectBackground(color, corner_radius));
title_->SetBounds(text_horizontal_inset, text_vertical_inset, text_width,
text_height);
}
if (views::FocusRing::Get(this))
views::FocusRing::Get(this)->Layout();
}
void TabGroupHeader::RemoveObserverFromWidget(views::Widget* widget) {
widget->RemoveObserver(&editor_bubble_tracker_);
}
BEGIN_METADATA(TabGroupHeader, views::View)
ADD_READONLY_PROPERTY_METADATA(int, DesiredWidth)
END_METADATA
TabGroupHeader::EditorBubbleTracker::~EditorBubbleTracker() {
if (is_open_) {
widget_->RemoveObserver(this);
widget_->CloseWithReason(views::Widget::ClosedReason::kUnspecified);
}
CHECK(!IsInObserverList());
}
void TabGroupHeader::EditorBubbleTracker::Opened(views::Widget* bubble_widget) {
DCHECK(bubble_widget);
DCHECK(!is_open_);
widget_ = bubble_widget;
is_open_ = true;
bubble_widget->AddObserver(this);
}
void TabGroupHeader::EditorBubbleTracker::OnWidgetDestroyed(
views::Widget* widget) {
is_open_ = false;
}