| // 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_group_view.h" |
| |
| #include <numeric> |
| |
| #include "chrome/browser/ui/browser_window/public/browser_window_features.h" |
| #include "chrome/browser/ui/browser_window/public/browser_window_interface.h" |
| #include "chrome/browser/ui/layout_constants.h" |
| #include "chrome/browser/ui/tabs/tab_group_features.h" |
| #include "chrome/browser/ui/tabs/tab_group_theme.h" |
| #include "chrome/browser/ui/tabs/vertical_tab_strip_state_controller.h" |
| #include "chrome/browser/ui/views/tabs/tab_group_accessibility.h" |
| #include "chrome/browser/ui/views/tabs/tab_hover_card_controller.h" |
| #include "chrome/browser/ui/views/tabs/tab_strip_types.h" |
| #include "chrome/browser/ui/views/tabs/vertical/tab_collection_animating_layout_manager.h" |
| #include "chrome/browser/ui/views/tabs/vertical/tab_collection_node.h" |
| #include "chrome/browser/ui/views/tabs/vertical/vertical_dragged_tabs_container.h" |
| #include "chrome/browser/ui/views/tabs/vertical/vertical_split_tab_view.h" |
| #include "chrome/browser/ui/views/tabs/vertical/vertical_tab_group_header_view.h" |
| #include "chrome/browser/ui/views/tabs/vertical/vertical_tab_strip_controller.h" |
| #include "chrome/browser/ui/views/tabs/vertical/vertical_tab_view.h" |
| #include "chrome/grit/generated_resources.h" |
| #include "components/data_sharing/public/features.h" |
| #include "components/saved_tab_groups/public/features.h" |
| #include "components/tabs/public/tab_collection_storage.h" |
| #include "components/tabs/public/tab_group.h" |
| #include "components/tabs/public/tab_group_tab_collection.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/color/color_provider.h" |
| #include "ui/gfx/color_utils.h" |
| #include "ui/gfx/geometry/rect.h" |
| #include "ui/gfx/geometry/rounded_corners_f.h" |
| #include "ui/gfx/text_elider.h" |
| #include "ui/views/background.h" |
| #include "ui/views/controls/button/label_button.h" |
| #include "ui/views/controls/scroll_view.h" |
| #include "ui/views/layout/delegating_layout_manager.h" |
| #include "ui/views/layout/proposed_layout.h" |
| #include "ui/views/view.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/view_utils.h" |
| #include "ui/views/widget/widget.h" |
| |
| namespace { |
| constexpr int kTabVerticalPadding = 2; |
| constexpr int kGroupLineWidth = 2; |
| constexpr int kGroupLineCollapsedLeadingPadding = 2; |
| constexpr int kGroupLineCornerRadius = 4; |
| constexpr int kGroupHeaderHeight = 26; |
| constexpr int kGroupHeaderVerticalMargin = 4; |
| constexpr int kTabLeadingPadding = 10; |
| |
| const TabGroup* GetTabGroupFromNode(TabCollectionNode* node) { |
| CHECK(node); |
| return static_cast<const tabs::TabGroupTabCollection*>( |
| std::get<const tabs::TabCollection*>(node->GetNodeData())) |
| ->GetTabGroup(); |
| } |
| |
| bool SupportsDataSharing() { |
| return data_sharing::features::IsDataSharingFunctionalityEnabled(); |
| } |
| |
| } // namespace |
| |
| VerticalTabGroupView::VerticalTabGroupView(TabCollectionNode* collection_node) |
| : VerticalDraggedTabsContainer(static_cast<views::View&>(*this), |
| collection_node, |
| DragAxes::kVerticalOnly, |
| DragLayout::kVertical), |
| collection_node_(collection_node), |
| tab_group_visual_data_( |
| *GetTabGroupFromNode(collection_node_)->visual_data()), |
| group_header_(AddChildView(std::make_unique<VerticalTabGroupHeaderView>( |
| *this, |
| collection_node_->GetController()->GetStateController(), |
| &tab_group_visual_data_))), |
| group_line_(AddChildView(std::make_unique<views::View>())), |
| layout_manager_(*SetLayoutManager( |
| std::make_unique<TabCollectionAnimatingLayoutManager>( |
| std::make_unique<views::DelegatingLayoutManager>(this), |
| *this))) { |
| collection_node->set_remove_child_from_node(base::BindRepeating( |
| &TabCollectionAnimatingLayoutManager::AnimateAndDestroyChildView, |
| base::Unretained(&layout_manager_.get()))); |
| collection_node->set_attach_child_to_node(base::BindRepeating( |
| &VerticalTabGroupView::AttachChildView, base::Unretained(this))); |
| collection_node->set_detach_child_from_node(base::BindRepeating( |
| &VerticalTabGroupView::DetachChildView, base::Unretained(this))); |
| |
| node_destroyed_subscription_ = |
| collection_node_->RegisterWillDestroyCallback(base::BindOnce( |
| &VerticalTabGroupView::ResetCollectionNode, base::Unretained(this))); |
| |
| data_changed_subscription_ = |
| collection_node_->RegisterDataChangedCallback(base::BindRepeating( |
| &VerticalTabGroupView::OnDataChanged, base::Unretained(this))); |
| |
| attention_indicator_observation_.Observe(GetTabGroupFromNode(collection_node_) |
| ->GetTabGroupFeatures() |
| ->attention_indicator()); |
| OnDataChanged(); |
| } |
| |
| VerticalTabGroupView::~VerticalTabGroupView() = default; |
| |
| void VerticalTabGroupView::OnThemeChanged() { |
| views::View::OnThemeChanged(); |
| OnDataChanged(); |
| } |
| |
| views::ProposedLayout VerticalTabGroupView::CalculateProposedLayout( |
| const views::SizeBounds& size_bounds) const { |
| views::ProposedLayout layouts; |
| int width = 0; |
| int height = kGroupHeaderVerticalMargin; |
| bool is_tab_strip_collapsed = IsTabStripCollapsed(); |
| |
| gfx::Rect header_bounds; |
| gfx::Rect group_line_bounds; |
| group_line_bounds.set_width(kGroupLineWidth); |
| |
| // If the tab strip is collapsed then the group line should appear on the |
| // leading side of all grouped tabs and the header. |
| if (is_tab_strip_collapsed) { |
| group_line_bounds.set_x(kGroupLineCollapsedLeadingPadding); |
| group_line_bounds.set_y(height); |
| header_bounds.set_x( |
| GetLayoutConstant(LayoutConstant::kVerticalTabStripCollapsedPadding)); |
| } |
| |
| header_bounds.set_y(height); |
| header_bounds.set_height(kGroupHeaderHeight); |
| // If width is bounded, the group header should respect the width constraints |
| // and take up the available width excluding trailing horizontal padding. |
| if (size_bounds.width().is_bounded()) { |
| header_bounds.set_width(size_bounds.width().value() - header_bounds.x()); |
| } |
| layouts.child_layouts.emplace_back( |
| group_header_.get(), group_header_->GetVisible(), header_bounds); |
| height += |
| header_bounds.height() + kGroupHeaderVerticalMargin + kTabVerticalPadding; |
| width = std::max(width, header_bounds.width()); |
| |
| // If the tab strip is not collapsed then the group line is below and left |
| // aligned with the header. |
| if (!is_tab_strip_collapsed) { |
| group_line_bounds.set_x((kTabLeadingPadding - kGroupLineWidth) / 2); |
| group_line_bounds.set_y(height); |
| } |
| |
| const std::vector<views::View*> children = |
| collection_node_ ? collection_node_->GetDirectChildren() |
| : std::vector<views::View*>(); |
| |
| // Layout children in order. Children will have their preferred height and |
| // fill available width. |
| for (auto* child : children) { |
| gfx::Rect bounds = gfx::Rect(child->GetPreferredSize(size_bounds)); |
| |
| auto drag_data = GetVisualDataForDraggedView(*child); |
| CHECK(!drag_data || !drag_data->should_hide); |
| bounds.set_y(drag_data ? drag_data->offset.y() : height); |
| |
| // If the tab strip is not collapsed then the groups tabs should be inset. |
| bounds.set_x(is_tab_strip_collapsed |
| ? GetLayoutConstant( |
| LayoutConstant::kVerticalTabStripCollapsedPadding) |
| : kTabLeadingPadding); |
| // If width is bounded, child views should respect the width constraints |
| // and take up the available width excluding trailing horizontal padding. |
| if (size_bounds.width().is_bounded()) { |
| bounds.set_width(size_bounds.width().value() - bounds.x()); |
| } |
| layouts.child_layouts.emplace_back(child, child->GetVisible(), bounds); |
| height += bounds.height() + kTabVerticalPadding; |
| width = std::max(width, bounds.width()); |
| } |
| // Remove excess padding. |
| height -= kTabVerticalPadding; |
| |
| if (!children.empty()) { |
| group_line_bounds.set_height(height - group_line_bounds.y()); |
| } |
| layouts.child_layouts.emplace_back( |
| group_line_.get(), group_line_->GetVisible(), group_line_bounds); |
| |
| // Add extra padding below the group if not collapsed. |
| const bool is_collapsed = IsCollapsed(); |
| if (!is_collapsed) { |
| height += kTabVerticalPadding; |
| } |
| |
| layouts.host_size = gfx::Size( |
| width, is_collapsed |
| ? header_bounds.height() + (2 * kGroupHeaderVerticalMargin) |
| : height); |
| return layouts; |
| } |
| |
| void VerticalTabGroupView::OnAttentionStateChanged() { |
| if (!collection_node_) { |
| return; |
| } |
| |
| const TabGroup* group = GetTabGroupFromNode(collection_node_); |
| const bool has_attention = |
| SupportsDataSharing() && |
| group->GetTabGroupFeatures()->attention_indicator()->GetHasAttention(); |
| group_header_->OnAttentionStateChanged(has_attention); |
| } |
| |
| void VerticalTabGroupView::ToggleCollapsedState( |
| ToggleTabGroupCollapsedStateOrigin origin) { |
| // If the group is in the process of being closed, then ignore updates. |
| if (!collection_node_) { |
| return; |
| } |
| |
| collection_node_->GetController()->ToggleTabGroupCollapsedState( |
| GetTabGroupFromNode(collection_node_), origin); |
| InvalidateLayout(); |
| } |
| |
| views::Widget* VerticalTabGroupView::ShowGroupEditorBubble( |
| bool stop_context_menu_propagation) { |
| // If the group is in the process of being closed, then ignore updates. |
| if (!collection_node_) { |
| return nullptr; |
| } |
| |
| bool is_tab_strip_collapsed = IsTabStripCollapsed(); |
| // When the tab strip is collapsed, anchor to the group header, otherwise |
| // anchor to the editor bubble button. |
| views::View* anchor_view = |
| is_tab_strip_collapsed ? views::AsViewClass<views::View>(group_header_) |
| : views::AsViewClass<views::View>( |
| group_header_->editor_bubble_button()); |
| return collection_node_->GetController()->ShowGroupEditorBubble( |
| GetTabGroupFromNode(collection_node_)->id(), anchor_view, |
| stop_context_menu_propagation); |
| } |
| |
| bool VerticalTabGroupView::IsDragging() const { |
| if (!collection_node_ || !collection_node_->GetController()) { |
| return false; |
| } |
| return GetDragHandler().IsDragging(); |
| } |
| |
| bool VerticalTabGroupView::IsViewDragging(const views::View& child_view) const { |
| if (!collection_node_ || !collection_node_->GetController()) { |
| return false; |
| } |
| return GetDragHandler().IsViewDragging(child_view); |
| } |
| |
| bool VerticalTabGroupView::ShouldAnimateOpacityForAddAndRemove( |
| const views::View& child_view) const { |
| // Only animate opacity for tab views. |
| return views::IsViewClass<VerticalTabView>(&child_view); |
| } |
| |
| void VerticalTabGroupView::OnAnimationEnded() { |
| // For collapsed tab groups update child visibility only once animations have |
| // completed. This allows tabs to remain visible as the group animates closed. |
| if (tab_group_visual_data_.is_collapsed()) { |
| UpdateChildVisibilityForCollapseState(true); |
| } |
| } |
| |
| std::u16string VerticalTabGroupView::GetGroupContentString() const { |
| if (!collection_node_) { |
| return std::u16string(); |
| } |
| |
| const TabGroup* group = GetTabGroupFromNode(collection_node_); |
| if (group->tab_count() == 0) { |
| return std::u16string(); |
| } |
| |
| return tab_groups::GetGroupContentString(group); |
| } |
| |
| void VerticalTabGroupView::AttachChildView( |
| std::unique_ptr<views::View> child_view, |
| const gfx::Rect& previous_bounds_in_screen) { |
| if (IsCollapsed()) { |
| // When child views are added to a group when in collapsed state, |
| // expand it to reveal the newly added views. |
| ToggleCollapsedState(ToggleTabGroupCollapsedStateOrigin::kMenuAction); |
| } |
| layout_manager_->AnimateAndReparentView(std::move(child_view), |
| previous_bounds_in_screen); |
| } |
| |
| std::unique_ptr<views::View> VerticalTabGroupView::DetachChildView( |
| views::View* child_view) { |
| if (IsCollapsed()) { |
| // The child views are invisible in collapsed state. When child views |
| // are detached from the group while collapsed, reset its visibility. |
| child_view->SetVisible(true); |
| } |
| return RemoveChildViewT(child_view); |
| } |
| |
| void VerticalTabGroupView::ResetCollectionNode() { |
| attention_indicator_observation_.Reset(); |
| node_destroyed_subscription_ = {}; |
| data_changed_subscription_ = {}; |
| collection_node_ = nullptr; |
| } |
| |
| void VerticalTabGroupView::OnDataChanged() { |
| // If the group is in the process of being closed, then ignore updates. |
| if (!collection_node_) { |
| return; |
| } |
| |
| const TabGroup* group = GetTabGroupFromNode(collection_node_); |
| tab_group_visual_data_ = *group->visual_data(); |
| group_header_->OnDataChanged(&tab_group_visual_data_, GetIsShared()); |
| |
| // If the tab group is not collapsed update child visibility immediately. This |
| // allows tabs to be visible as they are animated in. |
| if (!tab_group_visual_data_.is_collapsed()) { |
| UpdateChildVisibilityForCollapseState(false); |
| } |
| |
| if (GetColorProvider()) { |
| SkColor color = GetColorProvider()->GetColor(GetTabGroupTabStripColorId( |
| tab_group_visual_data_.color(), GetWidget()->ShouldPaintAsActive())); |
| group_line_->SetBackground(views::CreateRoundedRectBackground( |
| color, gfx::RoundedCornersF(0, kGroupLineCornerRadius, |
| kGroupLineCornerRadius, 0))); |
| } |
| InvalidateLayout(); |
| } |
| |
| void VerticalTabGroupView::UpdateChildVisibilityForCollapseState( |
| bool collapsed) { |
| // Collection node may not exist at this point during browser shutdown. |
| if (!collection_node_) { |
| return; |
| } |
| group_line_->SetVisible(!collapsed); |
| for (auto* child : collection_node_->GetDirectChildren()) { |
| child->SetVisible(!collapsed); |
| } |
| } |
| |
| bool VerticalTabGroupView::IsCollapsed() const { |
| return tab_group_visual_data_.is_collapsed(); |
| } |
| |
| bool VerticalTabGroupView::IsTabStripCollapsed() const { |
| const auto* controller = |
| collection_node_ ? collection_node_->GetController() : nullptr; |
| return controller && controller->IsCollapsed(); |
| } |
| |
| views::ScrollView* VerticalTabGroupView::GetScrollViewForContainer() const { |
| return views::ScrollView::GetScrollViewForContents( |
| const_cast<views::View*>(parent())); |
| } |
| |
| void VerticalTabGroupView::UpdateTargetLayoutForDrag( |
| const std::vector<const views::View*>& views_to_snap) { |
| layout_manager_->ResetViewsToTargetLayout(views_to_snap); |
| } |
| |
| const views::ProposedLayout& VerticalTabGroupView::GetLayoutForDrag() const { |
| return layout_manager_->target_layout(); |
| } |
| |
| void VerticalTabGroupView::HandleTabDragInContainer( |
| const gfx::Rect& dragged_tab_bounds) { |
| CHECK(!IsCollapsed()); |
| views::View* view_at_point = GetViewForDragBounds( |
| layout_manager_->target_layout(), dragged_tab_bounds); |
| const TabCollectionNode* node = nullptr; |
| if (auto* tab_view = views::AsViewClass<VerticalTabView>(view_at_point)) { |
| node = tab_view->collection_node(); |
| } else if (auto* split_tab_view = |
| views::AsViewClass<VerticalSplitTabView>(view_at_point)) { |
| node = split_tab_view->collection_node(); |
| } |
| if (node) { |
| GetDragHandler().HandleDraggedTabsOverNode(*node, std::nullopt); |
| // Synchronously force a layout here to update the target layout. Since all |
| // the calculations are based off on target layout, we need to ensure it is |
| // updated where there are model change. |
| DeprecatedLayoutImmediately(); |
| } |
| } |
| |
| bool VerticalTabGroupView::GetIsShared() { |
| CHECK(collection_node_); |
| if (!SupportsDataSharing()) { |
| return false; |
| } |
| |
| tab_groups::TabGroupSyncService* tab_group_service = |
| collection_node_->GetController()->GetTabGroupSyncService(); |
| if (!tab_group_service) { |
| return false; |
| } |
| |
| std::optional<tab_groups::SavedTabGroup> saved_group = |
| tab_group_service->GetGroup(GetTabGroupFromNode(collection_node_)->id()); |
| |
| return saved_group && saved_group->is_shared_tab_group(); |
| } |
| |
| void VerticalTabGroupView::OnTabDragExited(const gfx::Point& point_in_screen) { |
| if (!IsHandlingDrag()) { |
| // If the drag entered then exited in subsequent drag loop iterations, then |
| // the container will not have had a chance to handle the drag yet. |
| return; |
| } |
| auto dragging_tabs_bounds = GetDraggingViewsBoundsAtPoint( |
| views::View::ConvertPointFromScreen(this, point_in_screen)); |
| if (dragging_tabs_bounds.y() < 0) { |
| GetDragHandler().HandleDraggedTabsOutOfGroup(*collection_node_, |
| DragPositionHint::kBefore); |
| } else if (dragging_tabs_bounds.bottom() > height()) { |
| GetDragHandler().HandleDraggedTabsOutOfGroup(*collection_node_, |
| DragPositionHint::kAfter); |
| } |
| VerticalDraggedTabsContainer::OnTabDragExited(point_in_screen); |
| } |
| |
| std::optional<BrowserRootView::DropIndex> |
| VerticalTabGroupView::GetLinkDropIndex(const gfx::Point& loc_in_group) { |
| if (!collection_node_) { |
| return std::nullopt; |
| } |
| // Use the vertical position to find the child view being dragged over. |
| if (loc_in_group.y() < group_header_->bounds().bottom()) { |
| // Determine whether the drop is on the leading (top) or trailing |
| // (bottom) half of the header. If in the top half, then we the drag |
| // is considered to be above the group. |
| const bool is_leading = |
| loc_in_group.y() < group_header_->bounds().CenterPoint().y(); |
| return GetDragHandler().GetLinkDropIndexForNode( |
| *collection_node_, is_leading |
| ? std::make_optional(DragPositionHint::kBefore) |
| : std::nullopt); |
| } |
| |
| for (const auto& child_node : collection_node_->children()) { |
| auto* view = child_node->view(); |
| CHECK(view); |
| if (loc_in_group.y() > view->bounds().bottom()) { |
| continue; |
| } |
| |
| gfx::Point loc_in_child = |
| views::View::ConvertPointToTarget(this, view, loc_in_group); |
| |
| // If the drag is over the margins from the edges of the tab, then |
| // consider this drag as a before/after rather than over. |
| constexpr double kDragOverMargins = 0.2; |
| std::optional<DragPositionHint> hint; |
| if (loc_in_child.y() < view->height() * kDragOverMargins) { |
| hint = DragPositionHint::kBefore; |
| } else if (loc_in_child.y() > view->height() * (1 - kDragOverMargins)) { |
| hint = DragPositionHint::kAfter; |
| } else if (child_node->type() == TabCollectionNode::Type::SPLIT) { |
| // If landing in the middle of the split, let the split view decide which |
| // tab to replace. |
| auto* split_view = views::AsViewClass<VerticalSplitTabView>(view); |
| gfx::Point loc_in_split = |
| views::View::ConvertPointToTarget(this, split_view, loc_in_group); |
| return split_view->GetLinkDropIndex(loc_in_split); |
| } else { |
| hint = std::nullopt; |
| } |
| return GetDragHandler().GetLinkDropIndexForNode(*child_node, hint); |
| } |
| |
| // Fallback to the end of the group. |
| return GetDragHandler().GetLinkDropIndexForNode(*collection_node_, |
| DragPositionHint::kAfter); |
| } |
| |
| void VerticalTabGroupView::InitHeaderDrag(const ui::MouseEvent& event) { |
| CHECK(collection_node_); |
| GetDragHandler().InitializeDrag(*collection_node_, event); |
| } |
| |
| bool VerticalTabGroupView::ContinueHeaderDrag(const ui::MouseEvent& event) { |
| return GetDragHandler().ContinueDrag(*group_header_, event); |
| } |
| |
| void VerticalTabGroupView::CancelHeaderDrag() { |
| GetDragHandler().EndDrag(EndDragReason::kCancel); |
| } |
| |
| void VerticalTabGroupView::HideHoverCard() const { |
| if (!collection_node_) { |
| return; |
| } |
| |
| if (TabHoverCardController* hover_card_controller = |
| collection_node_->GetController()->GetHoverCardController()) { |
| hover_card_controller->UpdateHoverCard( |
| nullptr, TabSlotController::HoverCardUpdateType::kEvent); |
| } |
| } |
| |
| void VerticalTabGroupView::ShiftGroupUp() { |
| if (!collection_node_) { |
| return; |
| } |
| const TabGroup* group = GetTabGroupFromNode(collection_node_); |
| collection_node_->GetController()->ShiftGroupUp(group->id()); |
| } |
| |
| void VerticalTabGroupView::ShiftGroupDown() { |
| if (!collection_node_) { |
| return; |
| } |
| const TabGroup* group = GetTabGroupFromNode(collection_node_); |
| collection_node_->GetController()->ShiftGroupDown(group->id()); |
| } |
| |
| BEGIN_METADATA(VerticalTabGroupView) |
| END_METADATA |