diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index ebce4b37a..36e3bda40 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -208,6 +208,10 @@ class CommandDispatcher: raise cmdutils.CommandError( "-o is not supported with 'tabs.select_on_remove' set to " "'last-used'!") + elif conf_selection == 'firefox': + raise cmdutils.CommandError( + "-o is not supported with 'tabs.select_on_remove' set to " + "'firefox'!") else: # pragma: no cover raise ValueError("Invalid select_on_remove value " "{!r}!".format(conf_selection)) @@ -233,7 +237,7 @@ class CommandDispatcher: else: old_selection_behavior = tabbar.selectionBehaviorOnRemove() tabbar.setSelectionBehaviorOnRemove(selection_override) - self._tabbed_browser.close_tab(tab) + self._tabbed_browser.close_tab(tab, allow_selection_strategy=False) tabbar.setSelectionBehaviorOnRemove(old_selection_behavior) @cmdutils.register(instance='command-dispatcher', scope='window') diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index a64600652..d80709de5 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1890,6 +1890,11 @@ class SelectOnRemove(MappingType): QTabBar.SelectionBehavior.SelectPreviousTab, "Select the previously selected tab.", ), + 'firefox': ( + 'firefox', + ("Select the tab that was opened before this tab (if closed immediately " + "without switching away). Falls back to 'next' otherwise."), + ), } diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index e0938ae36..591a7ae8e 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -15,7 +15,7 @@ from typing import ( Any, Optional) from collections.abc import Mapping, MutableMapping, MutableSequence -from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication +from qutebrowser.qt.widgets import QSizePolicy, QWidget, QApplication, QTabBar from qutebrowser.qt.core import pyqtSignal, pyqtSlot, QTimer, QUrl, QPoint from qutebrowser.config import config @@ -135,6 +135,55 @@ class TabDeletedError(Exception): """Exception raised when _tab_index is called for a deleted tab.""" +class SelectionStrategy: + + """Base class for tab selection strategies (on remove).""" + + def on_tab_opened(self, _tabbed_browser: "TabbedBrowser", _tab: browsertab.AbstractTab, _background: bool) -> None: + """Called when a new tab is opened.""" + + def on_current_changed(self, _tab: browsertab.AbstractTab) -> None: + """Called when the current tab changes.""" + + def should_select_opener(self, _tab: browsertab.AbstractTab) -> bool: + """Check if we should return to the opener tab.""" + return False + + +class FirefoxSelectionStrategy(SelectionStrategy): + + """Strategy for Firefox-like "return to opener" behavior.""" + + def __init__(self) -> None: + self._opened_tab: Optional[weakref.ReferenceType[browsertab.AbstractTab]] = None + + def on_tab_opened(self, tabbed_browser: "TabbedBrowser", tab: browsertab.AbstractTab, background: bool) -> None: + # Track relationship + if tabbed_browser.widget.count() > 0: + if self._opened_tab is not None and background: + self._opened_tab = None + else: + self._opened_tab = weakref.ref(tab) + + def on_current_changed(self, tab: browsertab.AbstractTab) -> None: + # Clear state if user switched away + if self._opened_tab is not None: + opened = self._opened_tab() + if tab is not opened: + self._opened_tab = None + + def should_select_opener(self, tab: browsertab.AbstractTab) -> bool: + if self._opened_tab is None: + return False + + opened = self._opened_tab() + if opened is tab: + self._opened_tab = None # Consume state + return True + + return False + + class TabbedBrowser(QWidget): """A TabWidget with QWebViews inside. @@ -242,6 +291,8 @@ class TabbedBrowser(QWidget): self.default_window_icon = self._window().windowIcon() self.is_private = private self.tab_deque = TabDeque() + self._selection_strategy: SelectionStrategy = SelectionStrategy() + self._update_selection_strategy() config.instance.changed.connect(self._on_config_changed) quitter.instance.shutting_down.connect(self.shutdown) @@ -252,6 +303,19 @@ class TabbedBrowser(QWidget): # We can't resize a collections.deque so just recreate it >:( self.undo_stack = collections.deque(self.undo_stack, maxlen=newsize) + def _update_selection_strategy(self): + """Update the selection strategy based on config.""" + strategy_map = { + "default": SelectionStrategy, + "firefox": FirefoxSelectionStrategy, + } + + strategy_key = config.val.tabs.select_on_remove or "default" + strategy_cls = strategy_map.get(strategy_key, SelectionStrategy) + + if type(self._selection_strategy) is not strategy_cls: # pylint: disable=unidiomatic-typecheck + self._selection_strategy = strategy_cls() + def __repr__(self): return utils.get_repr(self, count=self.widget.count()) @@ -267,6 +331,8 @@ class TabbedBrowser(QWidget): self.widget.update_tab_titles() elif option == "tabs.focus_stack_size": self.tab_deque.update_size() + elif option == "tabs.select_on_remove": + self._update_selection_strategy() def _tab_index(self, tab): """Get the index of a given tab. @@ -444,7 +510,8 @@ class TabbedBrowser(QWidget): else: yes_action() - def close_tab(self, tab, *, add_undo=True, new_undo=True, transfer=False): + def close_tab(self, tab, *, add_undo=True, new_undo=True, transfer=False, + allow_selection_strategy=True): """Close a tab. Args: @@ -452,6 +519,7 @@ class TabbedBrowser(QWidget): add_undo: Whether the tab close can be undone. new_undo: Whether the undo entry should be a new item in the stack. transfer: Whether the tab is closing because it is moving to a new window. + allow_selection_strategy: Whether to try selecting the 'opener' tab (if configured). """ if config.val.tabs.tabs_are_windows or transfer: last_close = 'close' @@ -463,8 +531,18 @@ class TabbedBrowser(QWidget): if last_close == 'ignore' and count == 1: return + restore_behavior = None + if allow_selection_strategy and self._selection_strategy.should_select_opener(tab): + # Temporarily switch to 'last-used' behavior to select the opener + tabbar = self.widget.tab_bar() + restore_behavior = tabbar.selectionBehaviorOnRemove() + tabbar.setSelectionBehaviorOnRemove(QTabBar.SelectionBehavior.SelectPreviousTab) + self._remove_tab(tab, add_undo=add_undo, new_undo=new_undo) + if restore_behavior is not None: + self.widget.tab_bar().setSelectionBehaviorOnRemove(restore_behavior) + if count == 1: # We just closed the last tab above. if last_close == 'close': self.close_window.emit() @@ -659,6 +737,9 @@ class TabbedBrowser(QWidget): if background is None: background = config.val.tabs.background + + self._selection_strategy.on_tab_opened(self, tab, background) + if background: # Make sure the background tab has the correct initial size. # With a foreground tab, it's going to be resized correctly by the @@ -904,6 +985,9 @@ class TabbedBrowser(QWidget): .format(idx)) return + # Clear state if user switched to a tab that's not the opened tab + self._selection_strategy.on_current_changed(tab) + log.modes.debug("Current tab changed, focusing {!r}".format(tab)) tab.setFocus() diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 8d50ac45d..8a6e686a0 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -70,7 +70,12 @@ class TabWidget(QTabWidget): tabbar = self.tab_bar() tabbar.vertical = position in [ QTabWidget.TabPosition.West, QTabWidget.TabPosition.East] - tabbar.setSelectionBehaviorOnRemove(selection_behavior) + # 'firefox' mode uses custom selection logic; fall back to 'next' for Qt + if selection_behavior == 'firefox': + tabbar.setSelectionBehaviorOnRemove( + QTabBar.SelectionBehavior.SelectRightTab) + else: + tabbar.setSelectionBehaviorOnRemove(selection_behavior) tabbar.refresh() def tab_bar(self) -> "TabBar": diff --git a/tests/end2end/features/tabs_firefox.feature b/tests/end2end/features/tabs_firefox.feature new file mode 100644 index 000000000..87bfbd123 --- /dev/null +++ b/tests/end2end/features/tabs_firefox.feature @@ -0,0 +1,109 @@ +Feature: Tab selection on remove (firefox behavior) + Tests for tabs.select_on_remove = firefox + + Background: + Given I clean up open tabs + And I set tabs.tabs_are_windows to false + And I set tabs.background to false + And I clear the log + + Scenario: :tab-close with tabs.select_on_remove = firefox + When I set tabs.select_on_remove to firefox + And I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 2 + And I run :tab-close + Then the following tabs should be open: + """ + - data/numbers/1.txt + - data/numbers/3.txt (active) + - data/numbers/4.txt + """ + + Scenario: :tab-close with tabs.select_on_remove = firefox + When I set tabs.select_on_remove to firefox + And I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I run :tab-focus 1 + And I open data/numbers/4.txt in a new tab + And I run :tab-close + Then the following tabs should be open: + """ + - data/numbers/1.txt (active) + - data/numbers/2.txt + - data/numbers/3.txt + """ + + Scenario: Error with --opposite + When I set tabs.select_on_remove to firefox + And I run :tab-close --opposite + Then the error "-o is not supported with 'tabs.select_on_remove' set to 'firefox'!" should be shown + + Scenario: Override with --next + When I set tabs.select_on_remove to firefox + And I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 2 + And I run :tab-close --next + Then the following tabs should be open: + """ + - data/numbers/1.txt + - data/numbers/3.txt (active) + - data/numbers/4.txt + """ + + Scenario: Override with --prev + When I set tabs.select_on_remove to firefox + And I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 3 + And I run :tab-close --prev + Then the following tabs should be open: + """ + - data/numbers/1.txt + - data/numbers/2.txt (active) + - data/numbers/4.txt + """ + + Scenario: :tab-close with tabs.select_on_remove = firefox and --opposite + When I set tabs.select_on_remove to firefox + And I run :tab-close --opposite + Then the error "-o is not supported with 'tabs.select_on_remove' set to 'firefox'!" should be shown + + Scenario: Opening a second background tab forgets the state + When I set tabs.select_on_remove to firefox + And I open data/numbers/1.txt + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 1 + And I open data/numbers/2.txt in a new background tab + And I open data/numbers/3.txt in a new background tab + And I run :tab-focus 3 + And I run :tab-close + Then the following tabs should be open: + """ + - data/numbers/1.txt + - data/numbers/2.txt + - data/numbers/4.txt (active) + """ + + Scenario: Opening a foreground tab creates a state + When I set tabs.select_on_remove to firefox + And I open data/numbers/1.txt + And I open data/numbers/4.txt in a new tab + And I run :tab-focus 1 + And I open data/numbers/2.txt in a new background tab + And I open data/numbers/3.txt in a new tab + And I run :tab-close + Then the following tabs should be open: + """ + - data/numbers/1.txt (active) + - data/numbers/4.txt + - data/numbers/2.txt + """