From 5bf3647abc7026f0a327a7cae8ca4e066dce0393 Mon Sep 17 00:00:00 2001 From: markwai Date: Fri, 10 Apr 2026 10:41:30 -0700 Subject: [PATCH] helium/ui: add MRU tab cycling popup behind experimental flag Add a centered popup overlay for MRU tab cycling, gated behind the --helium-tab-cycling-popup chrome://flags entry (disabled by default). When the flag is enabled and Ctrl+Tab is pressed with MRU cycling active, a popup lists tabs in most-recently-used order. The highlight moves with each Tab press and activation is deferred until Ctrl is released. When the flag is disabled, tab cycling behaves as before with immediate activation on each step. --- i18n/source.gen.json | 18 + .../tab-cycling-popup-wiring.patch | 50 ++ patches/helium/ui/tab-cycling-mru-popup.patch | 584 ++++++++++++++++++ patches/series | 2 + 4 files changed, 654 insertions(+) create mode 100644 patches/helium/ui/experiments/tab-cycling-popup-wiring.patch create mode 100644 patches/helium/ui/tab-cycling-mru-popup.patch diff --git a/i18n/source.gen.json b/i18n/source.gen.json index e8e37696e..794878094 100644 --- a/i18n/source.gen.json +++ b/i18n/source.gen.json @@ -575,6 +575,24 @@ "context": "Description for the toggle to pin the top bar when in Zen mode.", "message": "Always show the top bar" }, + { + "name": "IDS_TAB_CYCLING_POPUP_ACCESSIBLE_NAME", + "source": "chrome/app/settings_strings.grdp", + "context": "Screen reader label for the MRU tab cycling popup.", + "message": "Recently used tabs" + }, + { + "name": "IDS_TAB_CYCLING_AND_MORE_TABS", + "source": "chrome/app/settings_strings.grdp", + "context": "Footer shown at the bottom of the MRU tab cycling popup when additional tabs are not visible.", + "message": "{COUNT, plural,=1 {and 1 more...}other {and # more...}}" + }, + { + "name": "IDS_TAB_CYCLING_UNTITLED_TAB", + "source": "chrome/app/settings_strings.grdp", + "context": "Fallback label in the MRU tab cycling popup when a tab has no title.", + "message": "Untitled" + }, { "name": "IDS_SETTINGS_DEFAULT_BROWSER_SECONDARY", "source": "chrome/app/settings_chromium_strings.grdp", diff --git a/patches/helium/ui/experiments/tab-cycling-popup-wiring.patch b/patches/helium/ui/experiments/tab-cycling-popup-wiring.patch new file mode 100644 index 000000000..d83e65c64 --- /dev/null +++ b/patches/helium/ui/experiments/tab-cycling-popup-wiring.patch @@ -0,0 +1,50 @@ +--- a/chrome/browser/ui/ui_features.cc ++++ b/chrome/browser/ui/ui_features.cc +@@ -32,6 +32,12 @@ bool IsHeliumZenModeFeatureEnabled() { + return base::FeatureList::IsEnabled(kHeliumZenMode); + } + ++BASE_FEATURE(kHeliumTabCyclingPopup, base::FEATURE_DISABLED_BY_DEFAULT); ++ ++bool IsHeliumTabCyclingPopupFeatureEnabled() { ++ return base::FeatureList::IsEnabled(kHeliumTabCyclingPopup); ++} ++ + // Enables the use of WGC for the Eye Dropper screen capture. + BASE_FEATURE(kAllowEyeDropperWGCScreenCapture, + #if BUILDFLAG(IS_WIN) +--- a/chrome/browser/ui/ui_features.h ++++ b/chrome/browser/ui/ui_features.h +@@ -25,6 +25,9 @@ bool HeliumUseCompactLocationWidth(); + BASE_DECLARE_FEATURE(kHeliumZenMode); + bool IsHeliumZenModeFeatureEnabled(); + ++BASE_DECLARE_FEATURE(kHeliumTabCyclingPopup); ++bool IsHeliumTabCyclingPopupFeatureEnabled(); ++ + BASE_DECLARE_FEATURE(kAllowEyeDropperWGCScreenCapture); + + BASE_DECLARE_FEATURE(kBrowserWidgetCacheThemeService); +--- a/chrome/browser/helium_flag_choices.h ++++ b/chrome/browser/helium_flag_choices.h +@@ -38,6 +38,8 @@ namespace helium { + constexpr const char kHeliumCompactLocationWidthCommandLine[] = + "helium-compact-location-width"; + constexpr const char kHeliumZenModeCommandLine[] = "helium-zen-mode"; ++ constexpr const char kHeliumTabCyclingPopupCommandLine[] = ++ "helium-tab-cycling-popup"; + } // namespace helium + + #endif /* CHROME_BROWSER_HELIUM_FLAG_CHOICES_H_ */ +--- a/chrome/browser/helium_flag_entries.h ++++ b/chrome/browser/helium_flag_entries.h +@@ -46,4 +46,9 @@ + "Hides browser chrome (toolbar, tab strip, bookmarks bar) and reveals it " + "on hover near the window edges. Helium flag.", + kOsDesktop, FEATURE_VALUE_TYPE(features::kHeliumZenMode)}, ++ {helium::kHeliumTabCyclingPopupCommandLine, ++ "Tab Cycling Popup", ++ "Shows a popup to select which tab to switch to when cycling tabs " ++ "in most recently used order. Helium flag.", ++ kOsDesktop, FEATURE_VALUE_TYPE(features::kHeliumTabCyclingPopup)}, + #endif /* CHROME_BROWSER_HELIUM_FLAG_ENTRIES_H_ */ diff --git a/patches/helium/ui/tab-cycling-mru-popup.patch b/patches/helium/ui/tab-cycling-mru-popup.patch new file mode 100644 index 000000000..e0e0fafee --- /dev/null +++ b/patches/helium/ui/tab-cycling-mru-popup.patch @@ -0,0 +1,584 @@ +--- /dev/null ++++ b/chrome/browser/ui/views/tabs/tab_cycling_popup_view.h +@@ -0,0 +1,84 @@ ++// Copyright 2026 The Helium Authors ++// You can use, redistribute, and/or modify this source code under ++// the terms of the GPL-3.0 license that can be found in the LICENSE file. ++ ++#ifndef CHROME_BROWSER_UI_VIEWS_TABS_TAB_CYCLING_POPUP_VIEW_H_ ++#define CHROME_BROWSER_UI_VIEWS_TABS_TAB_CYCLING_POPUP_VIEW_H_ ++ ++#include ++ ++#include "base/functional/callback.h" ++#include "base/memory/raw_ptr.h" ++#include "ui/base/metadata/metadata_header_macros.h" ++#include "ui/views/bubble/bubble_dialog_delegate_view.h" ++#include "ui/views/controls/button/label_button.h" ++ ++namespace ui { ++class ImageModel; ++} // namespace ui ++ ++class TabStripModel; ++ ++class TabCyclingRowButton : public views::LabelButton { ++ METADATA_HEADER(TabCyclingRowButton, views::LabelButton) ++ ++ public: ++ TabCyclingRowButton(const std::u16string& title, ++ const ui::ImageModel& favicon); ++ ~TabCyclingRowButton() override; ++ ++ TabCyclingRowButton(const TabCyclingRowButton&) = delete; ++ TabCyclingRowButton& operator=(const TabCyclingRowButton&) = delete; ++ ++ void SetHighlighted(bool highlighted); ++ ++ int tab_index() const { return tab_index_; } ++ void set_tab_index(int index) { tab_index_ = index; } ++ ++ protected: ++ bool OnMousePressed(const ui::MouseEvent& event) override; ++ void OnMouseEntered(const ui::MouseEvent& event) override; ++ void OnMouseExited(const ui::MouseEvent& event) override; ++ ++ private: ++ int tab_index_ = -1; ++}; ++ ++class TabCyclingPopupView : public views::BubbleDialogDelegateView { ++ METADATA_HEADER(TabCyclingPopupView, views::BubbleDialogDelegateView) ++ ++ public: ++ using DestroyCallback = base::OnceClosure; ++ ++ TabCyclingPopupView(views::View* anchor_view, ++ TabStripModel* tab_strip_model, ++ const std::vector& mru_list); ++ ~TabCyclingPopupView() override; ++ ++ TabCyclingPopupView(const TabCyclingPopupView&) = delete; ++ TabCyclingPopupView& operator=(const TabCyclingPopupView&) = delete; ++ ++ void SetDestroyCallback(DestroyCallback callback); ++ ++ void SetSelectedIndex(int index); ++ int GetSelectedIndex() const { return selected_index_; } ++ ++ int GetSelectedTabIndex() const; ++ ++ int GetRowCount() const { return static_cast(rows_.size()); } ++ ++ // views::BubbleDialogDelegateView: ++ gfx::Size CalculatePreferredSize( ++ const views::SizeBounds& available_size) const override; ++ void OnWidgetDestroying(views::Widget* widget) override; ++ ++ private: ++ void PopulateRows(TabStripModel* model, const std::vector& mru_list); ++ void UpdateHighlight(); ++ ++ std::vector> rows_; ++ int selected_index_ = 0; ++ DestroyCallback destroy_callback_; ++}; ++ ++#endif // CHROME_BROWSER_UI_VIEWS_TABS_TAB_CYCLING_POPUP_VIEW_H_ +--- /dev/null ++++ b/chrome/browser/ui/views/tabs/tab_cycling_popup_view.cc +@@ -0,0 +1,186 @@ ++// Copyright 2026 The Helium Authors ++// You can use, redistribute, and/or modify this source code under ++// the terms of the GPL-3.0 license that can be found in the LICENSE file. ++ ++#include "chrome/browser/ui/views/tabs/tab_cycling_popup_view.h" ++ ++#include ++ ++#include "base/strings/utf_string_conversions.h" ++#include "chrome/browser/favicon/favicon_utils.h" ++#include "chrome/browser/ui/tabs/tab_strip_model.h" ++#include "chrome/browser/ui/views/toolbar/toolbar_ink_drop_util.h" ++#include "chrome/grit/generated_resources.h" ++#include "content/public/browser/web_contents.h" ++#include "ui/accessibility/ax_enums.mojom.h" ++#include "ui/base/l10n/l10n_util.h" ++#include "ui/base/metadata/metadata_impl_macros.h" ++#include "ui/base/models/image_model.h" ++#include "ui/color/color_id.h" ++#include "ui/views/accessibility/view_accessibility.h" ++#include "ui/views/animation/ink_drop.h" ++#include "ui/views/background.h" ++#include "ui/views/bubble/bubble_border.h" ++#include "ui/views/controls/highlight_path_generator.h" ++#include "ui/views/controls/label.h" ++#include "ui/views/layout/box_layout.h" ++#include "ui/views/style/typography.h" ++ ++namespace { ++constexpr int kPopupWidth = 320; ++constexpr int kMaxVisibleRows = 10; ++constexpr int kRowPadding = 6; ++constexpr int kFaviconTitleSpacing = 10; ++constexpr int kCornerRadius = 8; ++} // namespace ++ ++TabCyclingRowButton::TabCyclingRowButton(const std::u16string& title, ++ const ui::ImageModel& favicon) ++ : LabelButton(PressedCallback(), title) { ++ SetImageModel(views::Button::STATE_NORMAL, favicon); ++ SetBorder(views::CreateEmptyBorder( ++ gfx::Insets::VH(kRowPadding, kRowPadding * 2))); ++ SetImageLabelSpacing(kFaviconTitleSpacing); ++ SetElideBehavior(gfx::ELIDE_TAIL); ++ SetMaxSize(gfx::Size(kPopupWidth, 0)); ++ ++ SetFocusBehavior(FocusBehavior::ACCESSIBLE_ONLY); ++ GetViewAccessibility().SetRole(ax::mojom::Role::kListBoxOption); ++ ++ ConfigureInkDropForToolbar( ++ this, std::make_unique( ++ gfx::Insets(), kCornerRadius / 2)); ++ views::InkDrop::Get(this)->GetInkDrop()->SetShowHighlightOnHover(false); ++} ++ ++TabCyclingRowButton::~TabCyclingRowButton() = default; ++ ++void TabCyclingRowButton::SetHighlighted(bool highlighted) { ++ if (highlighted) { ++ SetBackground(views::CreateRoundedRectBackground( ++ ui::kColorSysStateHoverOnSubtle, kCornerRadius / 2)); ++ } else { ++ SetBackground(nullptr); ++ } ++ GetViewAccessibility().SetIsSelected(highlighted); ++} ++ ++// Popup is keyboard-only; swallow all mouse input so rows do not steal clicks. ++bool TabCyclingRowButton::OnMousePressed(const ui::MouseEvent& event) { ++ return false; ++} ++ ++void TabCyclingRowButton::OnMouseEntered(const ui::MouseEvent& event) {} ++ ++void TabCyclingRowButton::OnMouseExited(const ui::MouseEvent& event) {} ++ ++BEGIN_METADATA(TabCyclingRowButton) ++END_METADATA ++ ++TabCyclingPopupView::TabCyclingPopupView( ++ views::View* anchor_view, ++ TabStripModel* tab_strip_model, ++ const std::vector& mru_list) ++ : BubbleDialogDelegateView(anchor_view, views::BubbleBorder::NONE) { ++ SetButtons(static_cast(ui::mojom::DialogButton::kNone)); ++ set_shadow(views::BubbleBorder::STANDARD_SHADOW); ++ set_corner_radius(kCornerRadius); ++ set_close_on_deactivate(false); ++ set_adjust_if_offscreen(false); ++ set_margins(gfx::Insets(kRowPadding)); ++ ++ SetLayoutManager(std::make_unique( ++ views::BoxLayout::Orientation::kVertical)); ++ ++ GetViewAccessibility().SetRole(ax::mojom::Role::kListBox); ++ GetViewAccessibility().SetName( ++ l10n_util::GetStringUTF16(IDS_TAB_CYCLING_POPUP_ACCESSIBLE_NAME)); ++ ++ PopulateRows(tab_strip_model, mru_list); ++} ++ ++TabCyclingPopupView::~TabCyclingPopupView() = default; ++ ++void TabCyclingPopupView::SetDestroyCallback(DestroyCallback callback) { ++ destroy_callback_ = std::move(callback); ++} ++ ++int TabCyclingPopupView::GetSelectedTabIndex() const { ++ if (selected_index_ >= 0 && ++ selected_index_ < static_cast(rows_.size())) { ++ return rows_[selected_index_]->tab_index(); ++ } ++ return -1; ++} ++ ++void TabCyclingPopupView::PopulateRows(TabStripModel* model, ++ const std::vector& mru_list) { ++ const int visible_count = ++ std::min(static_cast(mru_list.size()), kMaxVisibleRows); ++ ++ for (int i = 0; i < visible_count; ++i) { ++ const int tab_index = mru_list[i]; ++ content::WebContents* contents = model->GetWebContentsAt(tab_index); ++ if (!contents) ++ continue; ++ ++ ui::ImageModel favicon_image; ++ gfx::Image favicon = favicon::TabFaviconFromWebContents(contents); ++ if (!favicon.IsEmpty()) ++ favicon_image = ui::ImageModel::FromImage(favicon); ++ ++ std::u16string title = contents->GetTitle(); ++ if (title.empty()) { ++ std::string_view host = contents->GetLastCommittedURL().host(); ++ title = host.empty() ++ ? l10n_util::GetStringUTF16(IDS_TAB_CYCLING_UNTITLED_TAB) ++ : base::UTF8ToUTF16(host); ++ } ++ ++ auto* row = AddChildView( ++ std::make_unique(title, favicon_image)); ++ row->set_tab_index(tab_index); ++ rows_.push_back(row); ++ } ++ ++ const int remaining = ++ static_cast(mru_list.size()) - visible_count; ++ if (remaining > 0) { ++ auto* footer = AddChildView(std::make_unique( ++ l10n_util::GetPluralStringFUTF16( ++ IDS_TAB_CYCLING_AND_MORE_TABS, remaining))); ++ footer->SetHorizontalAlignment(gfx::ALIGN_CENTER); ++ footer->SetTextContext(views::style::CONTEXT_LABEL); ++ footer->SetTextStyle(views::style::STYLE_SECONDARY); ++ footer->SetBorder( ++ views::CreateEmptyBorder(gfx::Insets::VH(kRowPadding / 2, 0))); ++ } ++} ++ ++void TabCyclingPopupView::SetSelectedIndex(int index) { ++ selected_index_ = index; ++ UpdateHighlight(); ++} ++ ++void TabCyclingPopupView::UpdateHighlight() { ++ for (size_t i = 0; i < rows_.size(); ++i) { ++ rows_[i]->SetHighlighted(static_cast(i) == selected_index_); ++ } ++} ++ ++gfx::Size TabCyclingPopupView::CalculatePreferredSize( ++ const views::SizeBounds& available_size) const { ++ gfx::Size size = ++ views::BubbleDialogDelegateView::CalculatePreferredSize(available_size); ++ size.set_width(kPopupWidth); ++ return size; ++} ++ ++void TabCyclingPopupView::OnWidgetDestroying(views::Widget* widget) { ++ if (destroy_callback_) ++ std::move(destroy_callback_).Run(); ++ BubbleDialogDelegateView::OnWidgetDestroying(widget); ++} ++ ++BEGIN_METADATA(TabCyclingPopupView) ++END_METADATA +--- a/ui/views/bubble/bubble_dialog_delegate_view.h ++++ b/ui/views/bubble/bubble_dialog_delegate_view.h +@@ -75,6 +75,7 @@ class PluginVmInstallerView; + class ProfileMenuViewBase; + class RemoveSuggestionBubbleDialogDelegateView; + class StoragePressureBubbleView; ++class TabCyclingPopupView; + class TabGroupEditorBubbleView; + class TabHoverCardBubbleView; + class TestBubbleView; +@@ -832,6 +833,7 @@ class VIEWS_EXPORT BubbleDialogDelegateV + friend class ::ProfileMenuViewBase; + friend class ::RemoveSuggestionBubbleDialogDelegateView; + friend class ::StoragePressureBubbleView; ++ friend class ::TabCyclingPopupView; + friend class ::TabGroupEditorBubbleView; + friend class ::TabHoverCardBubbleView; + friend class ::TestBubbleView; +--- a/chrome/browser/ui/tabs/tab_strip_model.h ++++ b/chrome/browser/ui/tabs/tab_strip_model.h +@@ -43,6 +43,7 @@ + #error This file should only be included on desktop. + #endif + ++class Browser; + class DraggingTabsSession; + class Profile; + class TabGroupModel; +@@ -617,6 +618,13 @@ class TabStripModel { + // Used in BrowserView::StopTabCycling(). + void StopMRUCycling(); + ++ const std::vector& GetMRUCycleList() const { ++ return mru_cycle_list_; ++ } ++ ++ // -1 if no cycling session is active. ++ int GetPendingMRUTabIndex() const; ++ + // Moves the active in the specified direction. Respects group boundaries. + void MoveTabNext(); + void MoveTabPrevious(); +@@ -1488,6 +1496,9 @@ class TabStripModel { + // List of tabs for MRU cycling + std::vector mru_cycle_list_; + ++ // Cached browser for the current MRU cycling session. ++ raw_ptr mru_cycle_browser_ = nullptr; ++ + base::WeakPtrFactory weak_factory_{this}; + }; + +--- a/chrome/browser/ui/tabs/tab_strip_model.cc ++++ b/chrome/browser/ui/tabs/tab_strip_model.cc +@@ -4038,10 +4038,15 @@ void TabStripModel::SelectRelativeTabCyc + void TabStripModel::SelectMRUTab(TabRelativeDirection direction, + TabStripUserGestureDetails detail) { + if (mru_cycle_list_.empty()) { +- Browser* browser = chrome::FindBrowserWithTab(GetWebContentsAt(0)); ++ content::WebContents* contents = GetWebContentsAt(0); ++ if (!contents) { ++ return; ++ } ++ Browser* browser = chrome::FindBrowserWithTab(contents); + if (!browser) { + return; + } ++ mru_cycle_browser_ = browser; + + for (int i = 0; i < count(); ++i) { + mru_cycle_list_.push_back(i); +@@ -4066,11 +4071,25 @@ void TabStripModel::SelectMRUTab(TabRela + mru_cycle_list_.rend()); + } + +- ActivateTabAt(mru_cycle_list_[0], detail); ++ if (!mru_cycle_browser_) { ++ return; ++ } ++ BrowserView* browser_view = mru_cycle_browser_->window()->AsBrowserView(); ++ if (browser_view) { ++ browser_view->UpdateTabCyclingSelection( ++ direction == TabRelativeDirection::kNext); ++ } ++} ++ ++int TabStripModel::GetPendingMRUTabIndex() const { ++ if (mru_cycle_list_.empty()) ++ return -1; ++ return mru_cycle_list_[0]; + } + + void TabStripModel::StopMRUCycling() { + mru_cycle_list_.clear(); ++ mru_cycle_browser_ = nullptr; + } + + void TabStripModel::SelectRelativeTab(TabRelativeDirection direction, +--- a/chrome/browser/ui/views/frame/browser_view.h ++++ b/chrome/browser/ui/views/frame/browser_view.h +@@ -90,6 +90,7 @@ class ProjectsPanelView; + class ScrimView; + class SidePanel; + class TabCyclingEventHandler; ++class TabCyclingPopupView; + class TabDragTarget; + class TabSearchBubbleHost; + class TabStrip; +@@ -621,6 +622,7 @@ class BrowserView : public BrowserWindow + + void StartTabCycling(); + void StopTabCycling(); ++ void UpdateTabCyclingSelection(bool forward); + + SharingDialog* ShowSharingDialog(content::WebContents* contents, + SharingDialogData data) override; +@@ -1521,6 +1523,11 @@ class BrowserView : public BrowserWindow + base::OneShotTimer zen_cursor_exit_timer_; + bool zen_mode_enabled_ = false; + ++ bool EnsureTabCyclingPopup(); ++ ++ raw_ptr tab_cycling_popup_ = nullptr; ++ int tab_cycling_highlight_index_ = 0; ++ + mutable base::WeakPtrFactory weak_ptr_factory_{this}; + }; + +--- a/chrome/browser/ui/views/frame/browser_view.cc ++++ b/chrome/browser/ui/views/frame/browser_view.cc +@@ -206,6 +206,7 @@ + #include "chrome/browser/ui/views/tabs/shared/tab_strip_flat_edge_button.h" + #include "chrome/browser/ui/views/tabs/tab.h" + #include "chrome/browser/ui/views/tabs/tab/tab_accessibility.h" ++#include "chrome/browser/ui/views/tabs/tab_cycling_popup_view.h" + #include "chrome/browser/ui/views/tabs/tab_search_button.h" + #include "chrome/browser/ui/views/tabs/tab_strip.h" + #include "chrome/browser/ui/views/tabs/vertical/vertical_tab_strip_top_container.h" +@@ -869,11 +870,20 @@ class TabCyclingEventHandler : public ui + private: + // ui::EventObserver overrides: + void OnEvent(const ui::Event& event) override { +- if (event.type() == ui::EventType::kKeyReleased && +- event.AsKeyEvent()->key_code() == ui::VKEY_CONTROL) { +- // Ctrl key was released, stop the tab cycling. +- Stop(); +- return; ++ if (event.type() == ui::EventType::kKeyReleased) { ++ const ui::KeyboardCode code = event.AsKeyEvent()->key_code(); ++ bool stop_cycling = (code == ui::VKEY_CONTROL); ++#if BUILDFLAG(IS_MAC) ++ // Cmd or Option release also ends cycling on macOS. ++ stop_cycling = stop_cycling || ++ code == ui::VKEY_COMMAND || ++ code == ui::VKEY_MENU || ++ code == ui::VKEY_RMENU; ++#endif ++ if (stop_cycling) { ++ Stop(); ++ return; ++ } + } + + if (event.type() == ui::EventType::kMousePressed) { +@@ -922,11 +932,111 @@ class TabCyclingEventHandler : public ui + + void BrowserView::StartTabCycling() { + tab_cycling_event_handler_ = std::make_unique(this); ++ tab_cycling_highlight_index_ = 0; + } + + void BrowserView::StopTabCycling() { ++ TabStripModel* model = browser()->tab_strip_model(); ++ ++ if (tab_cycling_popup_) { ++ const int selected = tab_cycling_popup_->GetSelectedTabIndex(); ++ tab_cycling_popup_->GetWidget()->Close(); ++ tab_cycling_popup_ = nullptr; ++ ++ if (selected >= 0 && selected < model->count()) { ++ model->ActivateTabAt( ++ selected, ++ TabStripUserGestureDetails( ++ TabStripUserGestureDetails::GestureType::kOther)); ++ } ++ } ++ + tab_cycling_event_handler_.reset(); +- browser()->tab_strip_model()->StopMRUCycling(); ++ model->StopMRUCycling(); ++} ++ ++bool BrowserView::EnsureTabCyclingPopup() { ++ if (tab_cycling_popup_) ++ return false; ++ if (!features::IsHeliumTabCyclingPopupFeatureEnabled()) ++ return false; ++ ++ const auto& mru_list = ++ browser()->tab_strip_model()->GetMRUCycleList(); ++ if (mru_list.size() <= 1) ++ return false; ++ ++ auto popup = std::make_unique( ++ contents_web_view(), browser()->tab_strip_model(), mru_list); ++ TabCyclingPopupView* popup_raw = popup.get(); ++ views::Widget* bubble_widget = ++ views::BubbleDialogDelegateView::CreateBubble(std::move(popup)); ++ ++ if (!bubble_widget || bubble_widget->IsClosed()) ++ return false; ++ ++ if (popup_raw->GetRowCount() == 0) { ++ bubble_widget->Close(); ++ return false; ++ } ++ ++ tab_cycling_popup_ = popup_raw; ++ popup_raw->SetDestroyCallback(base::BindOnce( ++ [](base::WeakPtr bv) { ++ if (bv) { ++ bv->tab_cycling_popup_ = nullptr; ++ } ++ }, ++ weak_ptr_factory_.GetWeakPtr())); ++ ++ gfx::Rect browser_bounds = GetWidget()->GetWindowBoundsInScreen(); ++ gfx::Size widget_size = ++ bubble_widget->non_client_view()->GetPreferredSize(); ++ ++ const int max_height = browser_bounds.height() * 3 / 4; ++ if (widget_size.height() > max_height) ++ widget_size.set_height(std::max(max_height, 0)); ++ ++ gfx::Point center( ++ browser_bounds.x() + ++ (browser_bounds.width() - widget_size.width()) / 2, ++ browser_bounds.y() + ++ (browser_bounds.height() - widget_size.height()) / 2); ++ bubble_widget->SetBounds(gfx::Rect(center, widget_size)); ++ bubble_widget->ShowInactive(); ++ tab_cycling_popup_->SetSelectedIndex(0); ++ return true; ++} ++ ++void BrowserView::UpdateTabCyclingSelection(bool forward) { ++ const bool just_created = EnsureTabCyclingPopup(); ++ ++ if (!tab_cycling_popup_) { ++ int pending = browser()->tab_strip_model()->GetPendingMRUTabIndex(); ++ if (pending >= 0 && pending < browser()->tab_strip_model()->count()) { ++ browser()->tab_strip_model()->ActivateTabAt( ++ pending, ++ TabStripUserGestureDetails( ++ TabStripUserGestureDetails::GestureType::kOther)); ++ } ++ return; ++ } ++ ++ if (just_created) ++ return; ++ ++ const int row_count = tab_cycling_popup_->GetRowCount(); ++ if (row_count == 0) ++ return; ++ ++ if (forward) { ++ tab_cycling_highlight_index_ = ++ (tab_cycling_highlight_index_ + 1) % row_count; ++ } else { ++ tab_cycling_highlight_index_ = ++ (tab_cycling_highlight_index_ - 1 + row_count) % row_count; ++ } ++ tab_cycling_popup_->SetSelectedIndex(tab_cycling_highlight_index_); + } + + class BrowserView::AccessibilityModeObserver : public ui::AXModeObserver { +--- a/chrome/browser/ui/BUILD.gn ++++ b/chrome/browser/ui/BUILD.gn +@@ -4519,6 +4519,8 @@ static_library("ui") { + "views/tabs/tab_container_controller.h", + "views/tabs/tab_container_impl.cc", + "views/tabs/tab_container_impl.h", ++ "views/tabs/tab_cycling_popup_view.cc", ++ "views/tabs/tab_cycling_popup_view.h", + "views/tabs/tab_group_accessibility.cc", + "views/tabs/tab_group_accessibility.h", + "views/tabs/tab_group_editor_bubble_tracker.cc", +--- a/chrome/app/settings_strings.grdp ++++ b/chrome/app/settings_strings.grdp +@@ -325,6 +325,17 @@ + + Always show the top bar + ++ ++ Recently used tabs ++ ++ ++ {COUNT, plural, ++ =1 {and 1 more...} ++ other {and # more...}} ++ ++ ++ Untitled ++ + + Show home button + diff --git a/patches/series b/patches/series index 30fab1550..c5f7a2956 100644 --- a/patches/series +++ b/patches/series @@ -301,3 +301,5 @@ helium/ui/fix-windows-ui-position.patch helium/ui/experiments/zen-mode-wiring.patch helium/ui/experiments/zen-caption-buttons.patch helium/ui/experiments/zen-mode.patch +helium/ui/experiments/tab-cycling-popup-wiring.patch +helium/ui/tab-cycling-mru-popup.patch