blob: ba135356b944db6b8fb8f77a8519e717620b2108 [file]
// 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/ui/views/tabs/vertical/vertical_tab_strip_controller.h"
#include <variant>
#include "base/metrics/histogram_functions.h"
#include "base/metrics/user_metrics.h"
#include "base/metrics/user_metrics_action.h"
#include "chrome/browser/tab_group_sync/tab_group_sync_service_factory.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_command_controller.h"
#include "chrome/browser/ui/browser_window/public/browser_window_features.h"
#include "chrome/browser/ui/tabs/saved_tab_groups/saved_tab_group_utils.h"
#include "chrome/browser/ui/tabs/split_tab_util.h"
#include "chrome/browser/ui/tabs/tab_group_model.h"
#include "chrome/browser/ui/tabs/tab_group_theme.h"
#include "chrome/browser/ui/tabs/tab_menu_model_factory.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/browser/ui/tabs/tab_strip_model_delegate.h"
#include "chrome/browser/ui/tabs/vertical_tab_strip_state_controller.h"
#include "chrome/browser/ui/ui_features.h"
#include "chrome/browser/ui/views/event_utils.h"
#include "chrome/browser/ui/views/frame/browser_view.h"
#include "chrome/browser/ui/views/frame/vertical_tab_strip_region_view.h"
#include "chrome/browser/ui/views/tabs/tab/tab_context_menu_controller.h"
#include "chrome/browser/ui/views/tabs/tab_group_accessibility.h"
#include "chrome/browser/ui/views/tabs/tab_group_editor_bubble_view.h"
#include "chrome/browser/ui/views/tabs/vertical/root_tab_collection_node.h"
#include "chrome/browser/ui/views/tabs/vertical/tab_collection_node.h"
#include "chrome/browser/ui/views/tabs/vertical/vertical_tab_drag_handler.h"
#include "chrome/browser/ui/views/tabs/vertical/vertical_tab_group_view.h"
#include "chrome/browser/ui/views/tabs/vertical/vertical_tab_view.h"
#include "chrome/grit/generated_resources.h"
#include "components/tabs/public/tab_collection_types.h"
#include "components/tabs/public/tab_group.h"
#include "components/tabs/public/tab_interface.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/views/view_utils.h"
#if BUILDFLAG(IS_CHROMEOS)
#include "chrome/browser/ui/web_applications/app_browser_controller.h"
#include "chromeos/ash/experiences/system_web_apps/types/system_web_app_delegate.h"
#endif // BUILDFLAG(IS_CHROMEOS)
VerticalTabStripController::VerticalTabStripController(
TabStripModel* model,
BrowserView* browser_view,
VerticalTabDragHandler& drag_handler,
TabHoverCardController* hover_card_controller,
std::unique_ptr<TabMenuModelFactory> menu_model_factory_override)
: model_(model),
browser_view_(browser_view),
drag_handler_(drag_handler),
hover_card_controller_(hover_card_controller) {
if (menu_model_factory_override) {
menu_model_factory_ = std::move(menu_model_factory_override);
} else {
menu_model_factory_ = std::make_unique<TabMenuModelFactory>();
}
}
VerticalTabStripController::~VerticalTabStripController() {
if (context_menu_controller_.get()) {
context_menu_controller_.reset();
}
}
void VerticalTabStripController::ShowContextMenuForNode(
TabCollectionNode* collection_node,
views::View* source,
const gfx::Point& point,
ui::mojom::MenuSourceType source_type) {
tabs::ConstChildPtr node_data = collection_node->GetNodeData();
CHECK(std::holds_alternative<const tabs::TabInterface*>(node_data));
const tabs::TabInterface* tab =
std::get<const tabs::TabInterface*>(node_data);
std::optional<int> tab_index =
tab->GetBrowserWindowInterface()->GetTabStripModel()->GetIndexOfTab(tab);
if (!tab_index.has_value()) {
return;
}
context_menu_controller_ =
std::make_unique<TabContextMenuController>(tab_index.value(), this);
auto model = menu_model_factory_->Create(
context_menu_controller_.get(),
browser_view_->browser()->GetFeatures().tab_menu_model_delegate(), model_,
tab_index.value());
context_menu_controller_->LoadModel(std::move(model));
context_menu_controller_->RunMenuAt(point, source_type, source->GetWidget());
}
void VerticalTabStripController::ShiftTabNext(
const tabs::TabInterface* tab_interface) {
ShiftTabRelative(tab_interface, 1);
}
void VerticalTabStripController::ShiftTabPrevious(
const tabs::TabInterface* tab_interface) {
ShiftTabRelative(tab_interface, -1);
}
void VerticalTabStripController::ShiftGroupUp(
const tab_groups::TabGroupId& group) {
ShiftGroupRelative(group, -1);
}
void VerticalTabStripController::ShiftGroupDown(
const tab_groups::TabGroupId& group) {
ShiftGroupRelative(group, 1);
}
void VerticalTabStripController::MoveTabFirst(
const tabs::TabInterface* tab_interface) {
const std::optional<int> start_index = model_->GetIndexOfTab(tab_interface);
if (!start_index.has_value()) {
return;
}
int target_index = 0;
if (!model_->IsTabPinned(start_index.value())) {
while (target_index < start_index && model_->IsTabPinned(target_index)) {
++target_index;
}
}
if (!model_->ContainsIndex(target_index)) {
return;
}
if (target_index != start_index) {
model_->MoveWebContentsAt(start_index.value(), target_index,
/*select_after_move=*/false);
}
// The tab may unintentionally land in the first group in the tab strip, so we
// remove the group to ensure consistent behavior. Even if the tab is already
// at the front, it should "move" out of its current group.
if (tab_interface->GetGroup().has_value()) {
model_->RemoveFromGroup({target_index});
}
browser_view_->GetViewAccessibility().AnnounceText(
l10n_util::GetStringUTF16(IDS_TAB_AX_ANNOUNCE_MOVED_FIRST));
}
void VerticalTabStripController::MoveTabLast(
const tabs::TabInterface* tab_interface) {
const std::optional<int> maybe_start_index =
model_->GetIndexOfTab(tab_interface);
if (!maybe_start_index.has_value()) {
return;
}
const int start_index = maybe_start_index.value();
int target_index;
if (model_->IsTabPinned(start_index)) {
int temp_index = start_index + 1;
while (temp_index < model_->count() && model_->IsTabPinned(temp_index)) {
++temp_index;
}
target_index = temp_index - 1;
} else {
target_index = model_->count() - 1;
}
if (!model_->ContainsIndex(target_index)) {
return;
}
if (target_index != start_index) {
model_->MoveWebContentsAt(start_index, target_index,
/*select_after_move=*/false);
}
// The tab may unintentionally land in the last group in the tab strip, so we
// remove the group to ensure consistent behavior. Even if the tab is already
// at the back, it should "move" out of its current group.
if (tab_interface->GetGroup().has_value()) {
model_->RemoveFromGroup({target_index});
}
browser_view_->GetViewAccessibility().AnnounceText(
l10n_util::GetStringUTF16(IDS_TAB_AX_ANNOUNCE_MOVED_LAST));
}
void VerticalTabStripController::SelectTab(
const tabs::TabInterface* tab_interface,
const TabStripUserGestureDetails& gesture_detail) {
std::optional<int> tab_index = model_->GetIndexOfTab(tab_interface);
if (!tab_index.has_value()) {
return;
}
if (!model_->IsTabInForeground(tab_index.value())) {
RecordMetricsOnTabSelectionChange(tab_interface->GetGroup());
}
std::optional<split_tabs::SplitTabId> split_id = tab_interface->GetSplit();
if (split_id.has_value()) {
tab_index = split_tabs::GetIndexOfLastActiveTab(model_, split_id.value());
}
model_->ActivateTabAt(tab_index.value(), gesture_detail);
}
void VerticalTabStripController::CloseTab(
const tabs::TabInterface* tab_interface) {
std::optional<int> tab_index = model_->GetIndexOfTab(tab_interface);
if (!tab_index.has_value()) {
return;
}
model_->CloseWebContentsAt(tab_index.value(),
TabCloseTypes::CLOSE_USER_GESTURE |
TabCloseTypes::CLOSE_CREATE_HISTORICAL_TAB);
}
void VerticalTabStripController::ToggleSelected(
const tabs::TabInterface* tab_interface) {
std::optional<int> tab_index = model_->GetIndexOfTab(tab_interface);
if (!tab_index.has_value()) {
return;
}
if (model_->IsTabSelected(tab_index.value())) {
model_->DeselectTabAt(tab_index.value());
} else {
model_->SelectTabAt(tab_index.value());
}
}
void VerticalTabStripController::AddSelectionFromAnchorTo(
const tabs::TabInterface* tab_interface) {
std::optional<int> tab_index = model_->GetIndexOfTab(tab_interface);
if (!tab_index.has_value()) {
return;
}
model_->AddSelectionFromAnchorTo(tab_index.value());
}
void VerticalTabStripController::ExtendSelectionTo(
const tabs::TabInterface* tab_interface) {
std::optional<int> tab_index = model_->GetIndexOfTab(tab_interface);
if (!tab_index.has_value()) {
return;
}
model_->ExtendSelectionTo(tab_index.value());
}
void VerticalTabStripController::ToggleTabGroupCollapsedState(
const TabGroup* group,
ToggleTabGroupCollapsedStateOrigin origin) {
bool is_currently_collapsed = group->visual_data()->is_collapsed();
bool should_toggle_group = true;
tabs::TabInterface* active_tab = model_->GetActiveTab();
if (!is_currently_collapsed && active_tab) {
if (active_tab->GetGroup() == group->id()) {
// If the active tab is in the group that is toggling to collapse, the
// active tab should switch to the next available tab. If there are no
// available tabs for the active tab to switch to, a new tab will
// be created.
const std::optional<int> next_active =
model_->GetNextExpandedActiveTab(group->id());
if (next_active.has_value()) {
model_->ActivateTabAt(
next_active.value(),
TabStripUserGestureDetails(
TabStripUserGestureDetails::GestureType::kOther));
} else {
// Create a new tab that will automatically be activated
should_toggle_group = false;
// We intentionally do not call CreateNewTab() here because it
// respects the IsNewTabAddsToActiveGroupEnabled() feature, which would
// add the new tab to the same group as the currently active tab.
// In the "collapse group" scenario, we want the new tab to be created
// outside of any group to avoid it being collapsed immediately.
model_->delegate()->AddTabAt(GURL(), -1, true);
}
} else {
// If the active tab is not in the group that is toggling to collapse,
// reactive the active tab to deselect any other potentially selected
// tabs.
SelectTab(active_tab,
TabStripUserGestureDetails(
TabStripUserGestureDetails::GestureType::kOther));
}
}
if (origin != ToggleTabGroupCollapsedStateOrigin::kMenuAction ||
should_toggle_group) {
model_->ChangeTabGroupVisuals(
group->id(),
tab_groups::TabGroupVisualData(group->visual_data()->title(),
group->visual_data()->color(),
!is_currently_collapsed),
true);
}
if (should_toggle_group &&
base::FeatureList::IsEnabled(features::kTabGroupsCollapseFreezing)) {
gfx::Range tabs_in_group = group->ListTabs();
for (uint32_t i = tabs_in_group.start(); i < tabs_in_group.end(); ++i) {
views::View* const view =
browser_view_->tab_strip_view()->GetTabAnchorViewAt(i);
CHECK(views::IsViewClass<VerticalTabView>(view));
VerticalTabView* const tab_view =
views::AsViewClass<VerticalTabView>(view);
if (is_currently_collapsed) {
tab_view->ReleaseFreezingVote();
} else {
tab_view->CreateFreezingVote();
}
}
}
const bool is_implicit_action =
origin == ToggleTabGroupCollapsedStateOrigin::kMenuAction ||
origin == ToggleTabGroupCollapsedStateOrigin::kTabsSelected;
if (!is_implicit_action) {
if (is_currently_collapsed) {
base::RecordAction(
base::UserMetricsAction("TabGroups_TabGroupHeader_Expanded"));
} else {
base::RecordAction(
base::UserMetricsAction("TabGroups_TabGroupHeader_Collapsed"));
}
}
}
void VerticalTabStripController::ShowGroupEditorBubble(
const TabCollectionNode* group_node) {
auto* group_header_view =
views::AsViewClass<VerticalTabGroupView>(group_node->view())
->group_header();
group_header_view->ShowContextMenuForViewImpl(
group_header_view, gfx::Point(), ui::mojom::MenuSourceType::kNone);
}
views::Widget* VerticalTabStripController::ShowGroupEditorBubble(
const tab_groups::TabGroupId& group_id,
views::View* anchor_view,
bool stop_context_menu_propagation) {
return TabGroupEditorBubbleView::Show(
browser_view_->browser(), group_id,
/*anchor_view=*/anchor_view, /*anchor_rect=*/std::nullopt,
/*stop_context_menu_propagation=*/stop_context_menu_propagation);
}
bool VerticalTabStripController::IsCollapsed() const {
const tabs::VerticalTabStripStateController* state_controller =
tabs::VerticalTabStripStateController::From(browser_view_->browser());
return state_controller && state_controller->IsCollapsed();
}
tab_groups::TabGroupSyncService*
VerticalTabStripController::GetTabGroupSyncService() {
return tab_groups::TabGroupSyncServiceFactory::GetForProfile(
browser_view_->GetProfile());
}
tabs::VerticalTabStripStateController*
VerticalTabStripController::GetStateController() {
return tabs::VerticalTabStripStateController::From(browser_view_->browser());
}
bool VerticalTabStripController::IsContextMenuCommandChecked(
TabStripModel::ContextMenuCommand command_id) {
return false;
}
bool VerticalTabStripController::IsContextMenuCommandEnabled(
int index,
TabStripModel::ContextMenuCommand command_id) {
return model_->IsContextMenuCommandEnabled(index, command_id);
}
bool VerticalTabStripController::IsContextMenuCommandAlerted(
TabStripModel::ContextMenuCommand command_id) {
return false;
}
void VerticalTabStripController::ExecuteContextMenuCommand(
int index,
TabStripModel::ContextMenuCommand command_id,
int event_flags) {
model_->ExecuteContextMenuCommand(index, command_id);
}
bool VerticalTabStripController::GetContextMenuAccelerator(
int command_id,
ui::Accelerator* accelerator) {
#if BUILDFLAG(IS_CHROMEOS)
auto* browser = browser_view_->browser();
auto* system_app = browser->app_controller()
? browser->app_controller()->system_app()
: nullptr;
if (system_app && !system_app->ShouldShowTabContextMenuShortcut(
browser->profile(), command_id)) {
return false;
}
#endif // BUILDFLAG(IS_CHROMEOS)
int browser_cmd;
return TabStripModel::ContextMenuCommandToBrowserCommand(command_id,
&browser_cmd) &&
browser_view_->GetWidget()->GetAccelerator(browser_cmd, accelerator);
}
void VerticalTabStripController::TabGroupFocusChanged(
std::optional<tab_groups::TabGroupId> new_focused_group_id,
std::optional<tab_groups::TabGroupId> old_focused_group_id) {
browser_view_->tab_strip_view()->OnTabGroupFocusChanged(new_focused_group_id,
old_focused_group_id);
std::optional<SkColor> color;
if (new_focused_group_id.has_value()) {
const TabGroup* group =
model_->group_model()->GetTabGroup(new_focused_group_id.value());
const tab_groups::TabGroupVisualData* visual_data = group->visual_data();
const auto* color_provider = browser_view_->GetColorProvider();
color = color_provider->GetColor(
GetTabGroupDialogColorId(visual_data->color()));
}
browser_view_->browser_widget()->SetUserColorOverride(color);
browser_view_->browser_widget()->ThemeChanged();
browser_view_->GetWidget()->non_client_view()->frame_view()->SchedulePaint();
}
void VerticalTabStripController::TabKeyboardFocusChangedTo(
const tabs::TabInterface* tab) {
std::optional<int> tab_index = std::nullopt;
if (tab) {
tab_index = model_->GetIndexOfTab(tab);
}
browser_view_->browser()->command_controller()->TabKeyboardFocusChangedTo(
tab_index);
}
void VerticalTabStripController::RecordMetricsOnTabSelectionChange(
std::optional<tab_groups::TabGroupId> group) {
base::UmaHistogramEnumeration("TabStrip.Tab.Views.ActivationAction",
TabActivationTypes::kTab);
if (!group) {
return;
}
base::RecordAction(base::UserMetricsAction("TabGroups_SwitchGroupedTab"));
if (!tab_groups::SavedTabGroupUtils::SupportsSharedTabGroups()) {
return;
}
tab_groups::TabGroupSyncService* tab_group_service =
tab_groups::TabGroupSyncServiceFactory::GetForProfile(
browser_view_->browser()->GetProfile());
if (!tab_group_service) {
return;
}
std::optional<tab_groups::SavedTabGroup> saved_group =
tab_group_service->GetGroup(group.value());
if (saved_group && saved_group->collaboration_id()) {
base::RecordAction(
base::UserMetricsAction("TabGroups.Shared.SwitchGroupedTab"));
}
}
void VerticalTabStripController::ShiftTabRelative(
const tabs::TabInterface* tab_interface,
int offset) {
CHECK_EQ(1, std::abs(offset))
<< "Offset must be 1 or -1 to shift tab up or down.";
const std::optional<int> maybe_start_index =
model_->GetIndexOfTab(tab_interface);
if (!maybe_start_index.has_value()) {
return;
}
const int start_index = maybe_start_index.value();
int target_index = start_index + offset;
const auto old_group = tab_interface->GetGroup();
if (!model_->ContainsIndex(target_index) ||
model_->IsTabPinned(start_index) != model_->IsTabPinned(target_index)) {
// Even if we've reached the boundary of where the tab could go, it may
// still be able to "move" out of its current group.
if (old_group.has_value()) {
AnnounceTabRemovedFromGroup(old_group.value());
model_->RemoveFromGroup({start_index});
}
return;
}
// If the tab is at a group boundary and the group is expanded, instead of
// actually moving the tab just change its group membership.
std::optional<tab_groups::TabGroupId> target_group =
model_->GetTabGroupForTab(target_index);
if (old_group != target_group) {
if (old_group.has_value()) {
AnnounceTabRemovedFromGroup(old_group.value());
model_->RemoveFromGroup({start_index});
return;
} else if (target_group.has_value()) {
// If the tab is at a group boundary and the group is collapsed, treat the
// collapsed group as a tab and find the next available slot for the tab
// to move to.
const TabGroup* group =
model_->group_model()->GetTabGroup(target_group.value());
if (group && group->visual_data()->is_collapsed()) {
int candidate_index = target_index + offset;
while (model_->ContainsIndex(candidate_index) &&
model_->GetTabGroupForTab(candidate_index) == target_group) {
candidate_index += offset;
}
if (model_->ContainsIndex(candidate_index)) {
target_index = candidate_index - offset;
} else {
target_index = offset < 0 ? 0 : model_->count() - 1;
}
} else {
// Read before adding the tab to the group so that the group description
// isn't the tab we just added.
AnnounceTabAddedToGroup(target_group.value());
model_->AddToExistingGroup({start_index}, target_group.value());
return;
}
}
}
model_->MoveWebContentsAt(start_index, target_index, false);
browser_view_->GetViewAccessibility().AnnounceText(
l10n_util::GetStringUTF16((offset > 0) ? IDS_TAB_AX_ANNOUNCE_MOVED_DOWN
: IDS_TAB_AX_ANNOUNCE_MOVED_UP));
}
void VerticalTabStripController::ShiftGroupRelative(
const tab_groups::TabGroupId& group,
int offset) {
CHECK_EQ(1, std::abs(offset))
<< "Offset must be 1 or -1 to shift the group up or down.";
const TabGroup* tab_group = model_->group_model()->GetTabGroup(group);
if (!tab_group) {
return;
}
gfx::Range tabs_in_group = tab_group->ListTabs();
const int start_index = tabs_in_group.start();
const int index_of_skipped_over_tab =
offset == 1 ? tabs_in_group.end() : start_index - 1;
if (!model_->ContainsIndex(start_index) ||
!model_->ContainsIndex(index_of_skipped_over_tab)) {
return;
}
if (model_->IsTabPinned(index_of_skipped_over_tab)) {
return;
}
// Avoid moving into the middle of another group by accounting for its size.
std::optional<tab_groups::TabGroupId> target_group =
model_->GetTabGroupForTab(index_of_skipped_over_tab);
if (target_group.has_value()) {
CHECK_NE(target_group.value(), group)
<< "The target group must be different from the current group to move.";
}
const int num_skipped_tabs = target_group.has_value()
? model_->group_model()
->GetTabGroup(target_group.value())
->ListTabs()
.length()
: 1;
const int target_index = start_index + offset * num_skipped_tabs;
model_->MoveGroupTo(group, target_index);
}
void VerticalTabStripController::AnnounceTabAddedToGroup(
tab_groups::TabGroupId group_id) {
auto* group = model_->group_model()->GetTabGroup(group_id);
const std::u16string group_title = group->visual_data()->title();
const std::u16string contents_string =
tab_groups::GetGroupContentString(group);
browser_view_->GetViewAccessibility().AnnounceText(
group_title.empty()
? l10n_util::GetStringFUTF16(
IDS_TAB_AX_ANNOUNCE_TAB_ADDED_TO_UNNAMED_GROUP, contents_string)
: l10n_util::GetStringFUTF16(
IDS_TAB_AX_ANNOUNCE_TAB_ADDED_TO_NAMED_GROUP, group_title,
contents_string));
}
void VerticalTabStripController::AnnounceTabRemovedFromGroup(
tab_groups::TabGroupId group_id) {
auto* group = model_->group_model()->GetTabGroup(group_id);
const std::u16string group_title = group->visual_data()->title();
const std::u16string contents_string =
tab_groups::GetGroupContentString(group);
browser_view_->GetViewAccessibility().AnnounceText(
group_title.empty()
? l10n_util::GetStringFUTF16(
IDS_TAB_AX_ANNOUNCE_TAB_REMOVED_FROM_UNNAMED_GROUP,
contents_string)
: l10n_util::GetStringFUTF16(
IDS_TAB_AX_ANNOUNCE_TAB_REMOVED_FROM_NAMED_GROUP, group_title,
contents_string));
}