diff --git a/README.asciidoc b/README.asciidoc index d2ee9e02d..6cb17e248 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -170,11 +170,11 @@ Contributors, sorted by the number of commits in descending order: * ZDarian * Milan Svoboda * John ShaggyTwoDope Jenkins +* Jimmy * Peter Vilim * Clayton Craft * Oliver Caldwell * Jonas Schürmann -* Jimmy * Panagiotis Ktistakis * Jakub Klinkovský * skinnay diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index d1875c645..ae7b597d3 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -11,6 +11,7 @@ |<>|Save the current page as a bookmark. |<>|Delete a bookmark. |<>|Load a bookmark. +|<>|Select tab by index or url/title best match. |<>|Close the current window. |<>|Download a given URL, or current page if no URL given. |<>|Cancel the last/[count]th download. @@ -142,6 +143,18 @@ Load a bookmark. * This command does not split arguments after the last argument and handles quotes literally. * With this command, +;;+ is interpreted literally instead of splitting off a second command. +[[buffer]] +=== buffer +Syntax: +:buffer 'index'+ + +Select tab by index or url/title best match. + +Focuses window if necessary. + +==== positional arguments +* +'index'+: The [win_id/]index of the tab to focus. Or a substring in which case the closest match will be focused. + + [[close]] === close Close the current window. diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 7ba630ddb..b22d3b8eb 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -35,7 +35,7 @@ from PyQt5.QtWidgets import QApplication from PyQt5.QtWebKit import QWebSettings from PyQt5.QtGui import QDesktopServices, QPixmap, QIcon, QCursor, QWindow from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QTimer, QUrl, - QObject, Qt, QEvent) + QObject, Qt, QEvent, pyqtSignal) try: import hunter except ImportError: @@ -742,6 +742,8 @@ class Application(QApplication): _args: ArgumentParser instance. """ + new_window = pyqtSignal(mainwindow.MainWindow) + def __init__(self, args): """Constructor. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 82a52cc3f..3cae6941c 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -45,6 +45,7 @@ from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils) from qutebrowser.utils.usertypes import KeyMode from qutebrowser.misc import editor, guiprocess +from qutebrowser.completion.models import instances, sortfilter class CommandDispatcher: @@ -834,6 +835,60 @@ class CommandDispatcher: raise cmdexc.CommandError(e) self._open(url, tab, bg, window) + @cmdutils.register(instance='command-dispatcher', scope='window', + completion=[usertypes.Completion.tab]) + def buffer(self, index): + """Select tab by index or url/title best match. + + Focuses window if necessary. + + Args: + index: The [win_id/]index of the tab to focus. Or a substring + in which case the closest match will be focused. + """ + index_parts = index.split('/', 1) + + try: + for part in index_parts: + int(part) + except ValueError: + model = instances.get(usertypes.Completion.tab) + sf = sortfilter.CompletionFilterModel(source=model) + sf.set_pattern(index) + if sf.count() > 0: + index = sf.data(sf.first_item()) + index_parts = index.split('/', 1) + else: + raise cmdexc.CommandError( + "No matching tab for: {}".format(index)) + + if len(index_parts) == 2: + win_id = int(index_parts[0]) + idx = int(index_parts[1]) + elif len(index_parts) == 1: + idx = int(index_parts[0]) + active_win = objreg.get('app').activeWindow() + if active_win is None: + # Not sure how you enter a command without an active window... + raise cmdexc.CommandError( + "No window specified and couldn't find active window!") + win_id = active_win.win_id + + if win_id not in objreg.window_registry: + raise cmdexc.CommandError( + "There's no window with id {}!".format(win_id)) + + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + if not 0 < idx <= tabbed_browser.count(): + raise cmdexc.CommandError( + "There's no tab with index {}!".format(idx)) + + window = objreg.window_registry[win_id] + window.activateWindow() + window.raise_() + tabbed_browser.setCurrentIndex(idx-1) + @cmdutils.register(instance='command-dispatcher', scope='window', count='count') def tab_focus(self, index: {'type': (int, 'last')}=None, count=None): diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py index 9d3cb2644..002a8a815 100644 --- a/qutebrowser/completion/models/instances.py +++ b/qutebrowser/completion/models/instances.py @@ -59,6 +59,14 @@ def _init_url_completion(): _instances[usertypes.Completion.url] = model +def _init_tab_completion(): + """Initialize the tab completion model.""" + log.completion.debug("Initializing tab completion.") + with debug.log_time(log.completion, 'tab completion init'): + model = miscmodels.TabCompletionModel() + _instances[usertypes.Completion.tab] = model + + def _init_setting_completions(): """Initialize setting completion models.""" log.completion.debug("Initializing setting completion.") @@ -115,6 +123,7 @@ INITIALIZERS = { usertypes.Completion.command: _init_command_completion, usertypes.Completion.helptopic: _init_helptopic_completion, usertypes.Completion.url: _init_url_completion, + usertypes.Completion.tab: _init_tab_completion, usertypes.Completion.section: _init_setting_completions, usertypes.Completion.option: _init_setting_completions, usertypes.Completion.value: _init_setting_completions, diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index e4cade0ab..400ea2568 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -19,6 +19,9 @@ """Misc. CompletionModels.""" +from PyQt5.QtCore import Qt, QTimer, pyqtSlot + +from qutebrowser.browser import webview from qutebrowser.config import config, configdata from qutebrowser.utils import objreg, log from qutebrowser.commands import cmdutils @@ -138,3 +141,77 @@ class SessionCompletionModel(base.BaseCompletionModel): self.new_item(cat, name) except OSError: log.completion.exception("Failed to list sessions!") + + +class TabCompletionModel(base.BaseCompletionModel): + + """A model to complete on open tabs across all windows. + + Used for switching the buffer command.""" + + # https://github.com/The-Compiler/qutebrowser/issues/545 + # pylint: disable=abstract-method + + #IDX_COLUMN = 0 + URL_COLUMN = 1 + TEXT_COLUMN = 2 + + COLUMN_WIDTHS = (6, 40, 54) + DUMB_SORT = Qt.DescendingOrder + + def __init__(self, parent=None): + super().__init__(parent) + + self.columns_to_filter = [self.URL_COLUMN, self.TEXT_COLUMN] + + for win_id in objreg.window_registry: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + for i in range(tabbed_browser.count()): + tab = tabbed_browser.widget(i) + tab.url_text_changed.connect(self.rebuild) + tab.shutting_down.connect(self.delayed_rebuild) + tabbed_browser.new_tab.connect(self.on_new_tab) + objreg.get("app").new_window.connect(self.on_new_window) + self.rebuild() + + # slot argument should be mainwindow.MainWindow but can't import + # that at module level because of import loops. + @pyqtSlot(object) + def on_new_window(self, window): + """Add hooks to new windows.""" + window.tabbed_browser.new_tab.connect(self.on_new_tab) + + @pyqtSlot(webview.WebView) + def on_new_tab(self, tab): + """Add hooks to new tabs.""" + tab.url_text_changed.connect(self.rebuild) + tab.shutting_down.connect(self.delayed_rebuild) + self.rebuild() + + @pyqtSlot() + def delayed_rebuild(self): + """Fire a rebuild indirectly so widgets get a chance to update.""" + QTimer.singleShot(0, self.rebuild) + + @pyqtSlot() + def rebuild(self): + """Rebuild completion model from current tabs. + + Very lazy method of keeping the model up to date. We could connect to + signals for new tab, tab url/title changed, tab close, tab moved and + make sure we handled background loads too ... but iterating over a + few/few dozen/few hundred tabs doesn't take very long at all. + """ + self.removeRows(0, self.rowCount()) + for win_id in objreg.window_registry: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + if tabbed_browser.shutting_down: + continue + c = self.new_category("{}".format(win_id)) + for i in range(tabbed_browser.count()): + tab = tabbed_browser.widget(i) + self.new_item(c, "{}/{}".format(win_id, i+1), + tab.url().toDisplayString(), + tabbed_browser.page_title(i)) diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 062ff0d95..f7fe0b706 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -187,6 +187,8 @@ class MainWindow(QWidget): #self.tabWidget.setCurrentIndex(0) #QtCore.QMetaObject.connectSlotsByName(MainWindow) + objreg.get("app").new_window.emit(self) + def __repr__(self): return utils.get_repr(self) diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index ef79b3ad5..7c6c4e9c8 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -63,7 +63,7 @@ class TabbedBrowser(tabwidget.TabWidget): tabbar -> new-tab-position set to 'left'. _tab_insert_idx_right: Same as above, for 'right'. _undo_stack: List of UndoEntry namedtuples of closed tabs. - _shutting_down: Whether we're currently shutting down. + shutting_down: Whether we're currently shutting down. Signals: cur_progress: Progress of the current tab changed (loadProgress). @@ -82,6 +82,7 @@ class TabbedBrowser(tabwidget.TabWidget): widget can adjust its size to it. arg: The new size. current_tab_changed: The current tab changed to the emitted WebView. + new_tab: Emits the new WebView and its index when a new tab is opened. """ cur_progress = pyqtSignal(int) @@ -96,13 +97,14 @@ class TabbedBrowser(tabwidget.TabWidget): resized = pyqtSignal('QRect') got_cmd = pyqtSignal(str) current_tab_changed = pyqtSignal(webview.WebView) + new_tab = pyqtSignal(webview.WebView, int) def __init__(self, win_id, parent=None): super().__init__(win_id, parent) self._win_id = win_id self._tab_insert_idx_left = 0 self._tab_insert_idx_right = -1 - self._shutting_down = False + self.shutting_down = False self.tabCloseRequested.connect(self.on_tab_close_requested) self.currentChanged.connect(self.on_current_changed) self.cur_load_started.connect(self.on_cur_load_started) @@ -234,7 +236,7 @@ class TabbedBrowser(tabwidget.TabWidget): def shutdown(self): """Try to shut down all tabs cleanly.""" - self._shutting_down = True + self.shutting_down = True for tab in self.widgets(): self._remove_tab(tab) @@ -398,6 +400,7 @@ class TabbedBrowser(tabwidget.TabWidget): if not background: self.setCurrentWidget(tab) tab.show() + self.new_tab.emit(tab, idx) return tab def _get_new_tab_idx(self, explicit): @@ -546,7 +549,7 @@ class TabbedBrowser(tabwidget.TabWidget): @pyqtSlot(int) def on_current_changed(self, idx): """Set last-focused-tab and leave hinting mode when focus changed.""" - if idx == -1 or self._shutting_down: + if idx == -1 or self.shutting_down: # closing the last tab (before quitting) or shutting down return tab = self.widget(idx) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 7ec0e57c7..5e29ee535 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -237,7 +237,7 @@ KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt', # Available command completions Completion = enum('Completion', ['command', 'section', 'option', 'value', 'helptopic', 'quickmark_by_name', - 'bookmark_by_url', 'url', 'sessions']) + 'bookmark_by_url', 'url', 'tab', 'sessions']) # Exit statuses for errors. Needs to be an int for sys.exit. diff --git a/tests/integration/features/tabs.feature b/tests/integration/features/tabs.feature index 8d898694b..630fa6279 100644 --- a/tests/integration/features/tabs.feature +++ b/tests/integration/features/tabs.feature @@ -709,3 +709,116 @@ Feature: Tab management - data/hints/link.html - about:blank - data/hello.txt (active) + + # :buffer + + Scenario: buffer without args + Given I have a fresh instance + When I run :buffer + Then the error "buffer: The following arguments are required: index" should be shown + + Scenario: buffer one window title present + When I open data/title.html + And I open data/search.html in a new tab + And I open data/scroll.html in a new tab + And I run :buffer "Searching text" + Then the following tabs should be open: + - data/title.html + - data/search.html (active) + - data/scroll.html + + Scenario: buffer one window title not present + When I run :buffer "invalid title" + Then the error "No matching tab for: invalid title" should be shown + + Scenario: buffer two window title present + When I open data/title.html + And I open data/search.html in a new tab + And I open data/scroll.html in a new tab + And I open data/caret.html in a new window + And I open data/paste_primary.html in a new tab + And I run :buffer "Scrolling" + Then the session should look like: + windows: + - active: true + tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + - history: + - url: http://localhost:*/data/search.html + - active: true + history: + - url: http://localhost:*/data/scroll.html + - tabs: + - history: + - url: http://localhost:*/data/caret.html + - active: true + history: + - url: http://localhost:*/data/paste_primary.html + + Scenario: buffer one window index not present + When I open data/title.html + And I run :buffer "666" + Then the error "There's no tab with index 666!" should be shown + + Scenario: buffer one window win not present + When I open data/title.html + And I run :buffer "2/1" + Then the error "There's no window with id 2!" should be shown + + Scenario: buffer two window index present + Given I have a fresh instance + When I open data/title.html + And I open data/search.html in a new tab + And I open data/scroll.html in a new tab + And I run :open -w http://localhost:(port)/data/caret.html + And I open data/paste_primary.html in a new tab + And I wait until data/caret.html is loaded + And I run :buffer "0/2" + Then the session should look like: + windows: + - active: true + tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + - active: true + history: + - url: http://localhost:*/data/search.html + - history: + - url: http://localhost:*/data/scroll.html + - tabs: + - history: + - url: http://localhost:*/data/caret.html + - active: true + history: + - url: http://localhost:*/data/paste_primary.html + + Scenario: buffer troubling args 01 + Given I have a fresh instance + When I open data/title.html + And I run :buffer "-1" + Then the error "There's no tab with index -1!" should be shown + + Scenario: buffer troubling args 02 + When I open data/title.html + And I run :buffer "/" + Then the following tabs should be open: + - data/title.html (active) + + Scenario: buffer troubling args 03 + When I open data/title.html + And I run :buffer "//" + Then the following tabs should be open: + - data/title.html (active) + + Scenario: buffer troubling args 04 + When I open data/title.html + And I run :buffer "0/x" + Then the error "No matching tab for: 0/x" should be shown + + Scenario: buffer troubling args 05 + When I open data/title.html + And I run :buffer "1/2/3" + Then the error "No matching tab for: 1/2/3" should be shown