summaryrefslogtreecommitdiff
path: root/qutebrowser
diff options
context:
space:
mode:
authorMarc Jauvin <marc.jauvin@gmail.com>2018-03-16 14:28:36 -0400
committerMarc Jauvin <marc.jauvin@gmail.com>2018-03-16 14:28:36 -0400
commitb7159d780a69daf104da939438938d262cd86000 (patch)
treeab9dc382617174d366dae51cda89186cfbd2bcb9 /qutebrowser
parentc9f6cd507b55dabe4d4d8f7841955837a634ff20 (diff)
parentf7074b80d0a68eec6fdfd13f2f82acc94ff2951e (diff)
downloadqutebrowser-b7159d780a69daf104da939438938d262cd86000.tar.gz
qutebrowser-b7159d780a69daf104da939438938d262cd86000.zip
Merge 'origin/master' into tab-input-mode
Diffstat (limited to 'qutebrowser')
-rw-r--r--qutebrowser/__init__.py2
-rw-r--r--qutebrowser/app.py14
-rw-r--r--qutebrowser/browser/browsertab.py45
-rw-r--r--qutebrowser/browser/commands.py85
-rw-r--r--qutebrowser/browser/downloads.py8
-rw-r--r--qutebrowser/browser/greasemonkey.py152
-rw-r--r--qutebrowser/browser/hints.py13
-rw-r--r--qutebrowser/browser/mouse.py5
-rw-r--r--qutebrowser/browser/network/proxy.py4
-rw-r--r--qutebrowser/browser/qutescheme.py25
-rw-r--r--qutebrowser/browser/shared.py15
-rw-r--r--qutebrowser/browser/signalfilter.py4
-rw-r--r--qutebrowser/browser/urlmarks.py2
-rw-r--r--qutebrowser/browser/webelem.py9
-rw-r--r--qutebrowser/browser/webengine/webengineinspector.py4
-rw-r--r--qutebrowser/browser/webengine/webenginesettings.py406
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py142
-rw-r--r--qutebrowser/browser/webengine/webview.py52
-rw-r--r--qutebrowser/browser/webkit/webkitsettings.py258
-rw-r--r--qutebrowser/browser/webkit/webkittab.py62
-rw-r--r--qutebrowser/browser/webkit/webpage.py66
-rw-r--r--qutebrowser/browser/webkit/webview.py4
-rw-r--r--qutebrowser/commands/__init__.py1
-rw-r--r--qutebrowser/commands/runners.py4
-rw-r--r--qutebrowser/completion/completer.py2
-rw-r--r--qutebrowser/completion/models/configmodel.py40
-rw-r--r--qutebrowser/completion/models/histcategory.py2
-rw-r--r--qutebrowser/completion/models/miscmodels.py6
-rw-r--r--qutebrowser/config/config.py236
-rw-r--r--qutebrowser/config/configcommands.py94
-rw-r--r--qutebrowser/config/configdata.py10
-rw-r--r--qutebrowser/config/configdata.yml61
-rw-r--r--qutebrowser/config/configexc.py22
-rw-r--r--qutebrowser/config/configfiles.py244
-rw-r--r--qutebrowser/config/configtypes.py47
-rw-r--r--qutebrowser/config/configutils.py186
-rw-r--r--qutebrowser/config/websettings.py261
-rw-r--r--qutebrowser/html/settings.html2
-rw-r--r--qutebrowser/html/tabs.html58
-rw-r--r--qutebrowser/html/undef_error.html22
-rw-r--r--qutebrowser/javascript/greasemonkey_wrapper.js38
-rw-r--r--qutebrowser/javascript/webelem.js9
-rw-r--r--qutebrowser/keyinput/basekeyparser.py299
-rw-r--r--qutebrowser/keyinput/keyparser.py77
-rw-r--r--qutebrowser/keyinput/keyutils.py558
-rw-r--r--qutebrowser/keyinput/modeman.py75
-rw-r--r--qutebrowser/keyinput/modeparsers.py201
-rw-r--r--qutebrowser/mainwindow/mainwindow.py12
-rw-r--r--qutebrowser/mainwindow/prompt.py31
-rw-r--r--qutebrowser/mainwindow/statusbar/backforward.py2
-rw-r--r--qutebrowser/mainwindow/statusbar/bar.py2
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py138
-rw-r--r--qutebrowser/mainwindow/tabwidget.py16
-rw-r--r--qutebrowser/misc/earlyinit.py5
-rw-r--r--qutebrowser/misc/editor.py41
-rw-r--r--qutebrowser/misc/keyhintwidget.py10
-rw-r--r--qutebrowser/misc/miscwidgets.py16
-rw-r--r--qutebrowser/misc/pastebin.py6
-rw-r--r--qutebrowser/misc/sessions.py9
-rw-r--r--qutebrowser/misc/utilcmds.py2
-rw-r--r--qutebrowser/utils/jinja.py11
-rw-r--r--qutebrowser/utils/objreg.py2
-rw-r--r--qutebrowser/utils/urlmatch.py293
-rw-r--r--qutebrowser/utils/usertypes.py22
-rw-r--r--qutebrowser/utils/utils.py290
-rw-r--r--qutebrowser/utils/version.py33
66 files changed, 3148 insertions, 1725 deletions
diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py
index 3da270437..31fd5983f 100644
--- a/qutebrowser/__init__.py
+++ b/qutebrowser/__init__.py
@@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2018 Florian Bruhin (The Compiler)"
__license__ = "GPL"
__maintainer__ = __author__
__email__ = "mail@qutebrowser.org"
-__version_info__ = (1, 1, 1)
+__version_info__ = (1, 2, 1)
__version__ = '.'.join(str(e) for e in __version_info__)
__description__ = "A keyboard-driven, vim-like browser based on PyQt5."
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
index ec477ce8f..3cce2e85e 100644
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -95,6 +95,7 @@ def run(args):
log.init.debug("Initializing directories...")
standarddir.init(args)
+ utils.preload_resources()
log.init.debug("Initializing config...")
configinit.early_init(args)
@@ -339,7 +340,7 @@ def _open_startpage(win_id=None):
for cur_win_id in list(window_ids): # Copying as the dict could change
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=cur_win_id)
- if tabbed_browser.count() == 0:
+ if tabbed_browser.widget.count() == 0:
log.init.debug("Opening start pages")
for url in config.val.url.start_pages:
tabbed_browser.tabopen(url)
@@ -772,6 +773,8 @@ class Quitter:
pre_text="Error while saving {}".format(key))
# Disable storage so removing tempdir will work
websettings.shutdown()
+ # Disable application proxy factory to fix segfaults with Qt 5.10.1
+ proxy.shutdown()
# Re-enable faulthandler to stdout, then remove crash log
log.destroy.debug("Deactivating crash log...")
objreg.get('crash-handler').destroy_crashlogfile()
@@ -840,7 +843,11 @@ class Application(QApplication):
def event(self, e):
"""Handle macOS FileOpen events."""
if e.type() == QEvent.FileOpen:
- open_url(e.url(), no_raise=True)
+ url = e.url()
+ if url.isValid():
+ open_url(url, no_raise=True)
+ else:
+ message.error("Invalid URL: {}".format(url.errorString()))
else:
return super().event(e)
@@ -878,6 +885,7 @@ class EventFilter(QObject):
self._handlers = {
QEvent.KeyPress: self._handle_key_event,
QEvent.KeyRelease: self._handle_key_event,
+ QEvent.ShortcutOverride: self._handle_key_event,
}
def _handle_key_event(self, event):
@@ -895,7 +903,7 @@ class EventFilter(QObject):
return False
try:
man = objreg.get('mode-manager', scope='window', window='current')
- return man.eventFilter(event)
+ return man.handle_event(event)
except objreg.RegistryUnavailableError:
# No window available yet, or not a MainWindow
return False
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 6ed5afe52..d3345c88b 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -30,7 +30,8 @@ from PyQt5.QtWidgets import QWidget, QApplication
from qutebrowser.keyinput import modeman
from qutebrowser.config import config
-from qutebrowser.utils import utils, objreg, usertypes, log, qtutils
+from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils,
+ urlutils, message)
from qutebrowser.misc import miscwidgets, objects
from qutebrowser.browser import mouse, hints
@@ -94,6 +95,8 @@ class TabData:
keep_icon: Whether the (e.g. cloned) icon should not be cleared on page
load.
inspector: The QWebInspector used for this webview.
+ open_target: Where to open the next link.
+ Only used for QtWebKit.
override_target: Override for open_target for fake clicks (like hints).
Only used for QtWebKit.
pinned: Flag to pin the tab.
@@ -104,6 +107,7 @@ class TabData:
keep_icon = attr.ib(False)
inspector = attr.ib(None)
+ open_target = attr.ib(usertypes.ClickTarget.normal)
override_target = attr.ib(None)
pinned = attr.ib(False)
fullscreen = attr.ib(False)
@@ -342,7 +346,7 @@ class AbstractCaret(QObject):
def _on_mode_entered(self, mode):
raise NotImplementedError
- def _on_mode_left(self):
+ def _on_mode_left(self, mode):
raise NotImplementedError
def move_to_next_line(self, count=1):
@@ -612,6 +616,7 @@ class AbstractTab(QWidget):
process terminated.
arg 0: A TerminationStatus member.
arg 1: The exit code.
+ predicted_navigation: Emitted before we tell Qt to open a URL.
"""
window_close_requested = pyqtSignal()
@@ -629,6 +634,7 @@ class AbstractTab(QWidget):
add_history_item = pyqtSignal(QUrl, QUrl, str) # url, requested url, title
fullscreen_requested = pyqtSignal(bool)
renderer_process_terminated = pyqtSignal(TerminationStatus, int)
+ predicted_navigation = pyqtSignal(QUrl)
def __init__(self, *, win_id, mode_manager, private, parent=None):
self.private = private
@@ -659,6 +665,8 @@ class AbstractTab(QWidget):
objreg.register('hintmanager', hintmanager, scope='tab',
window=self.win_id, tab=self.tab_id)
+ self.predicted_navigation.connect(self._on_predicted_navigation)
+
def _set_widget(self, widget):
# pylint: disable=protected-access
self._widget = widget
@@ -671,6 +679,7 @@ class AbstractTab(QWidget):
self.printing._widget = widget
self.action._widget = widget
self.elements._widget = widget
+ self.settings._settings = widget.settings()
self._install_event_filter()
self.zoom.set_default()
@@ -706,6 +715,14 @@ class AbstractTab(QWidget):
QApplication.postEvent(recipient, evt)
@pyqtSlot(QUrl)
+ def _on_predicted_navigation(self, url):
+ """Adjust the title if we are going to visit an URL soon."""
+ qtutils.ensure_valid(url)
+ url_string = url.toDisplayString()
+ log.webview.debug("Predicted navigation: {}".format(url_string))
+ self.title_changed.emit(url_string)
+
+ @pyqtSlot(QUrl)
def _on_url_changed(self, url):
"""Update title when URL has changed and no title is available."""
if url.isValid() and not self.title():
@@ -719,6 +736,23 @@ class AbstractTab(QWidget):
self._set_load_status(usertypes.LoadStatus.loading)
self.load_started.emit()
+ @pyqtSlot(usertypes.NavigationRequest)
+ def _on_navigation_request(self, navigation):
+ """Handle common acceptNavigationRequest code."""
+ url = utils.elide(navigation.url.toDisplayString(), 100)
+ log.webview.debug("navigation request: url {}, type {}, is_main_frame "
+ "{}".format(url,
+ navigation.navigation_type,
+ navigation.is_main_frame))
+
+ if (navigation.navigation_type == navigation.Type.link_clicked and
+ not navigation.url.isValid()):
+ msg = urlutils.get_errstring(navigation.url,
+ "Invalid link clicked")
+ message.error(msg)
+ self.data.open_target = usertypes.ClickTarget.normal
+ navigation.accepted = False
+
def handle_auto_insert_mode(self, ok):
"""Handle `input.insert_mode.auto_load` after loading finished."""
if not config.val.input.insert_mode.auto_load or not ok:
@@ -788,11 +822,12 @@ class AbstractTab(QWidget):
def load_status(self):
return self._load_status
- def _openurl_prepare(self, url):
+ def _openurl_prepare(self, url, *, predict=True):
qtutils.ensure_valid(url)
- self.title_changed.emit(url.toDisplayString())
+ if predict:
+ self.predicted_navigation.emit(url)
- def openurl(self, url):
+ def openurl(self, url, *, predict=True):
raise NotImplementedError
def reload(self, *, force=False):
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index b56c3d6ae..a4786e5e0 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -27,14 +27,13 @@ import typing
from PyQt5.QtWidgets import QApplication, QTabBar, QDialog
from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery
-from PyQt5.QtGui import QKeyEvent
from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog
from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners
from qutebrowser.config import config, configdata
from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate,
webelem, downloads)
-from qutebrowser.keyinput import modeman
+from qutebrowser.keyinput import modeman, keyutils
from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
objreg, utils, standarddir)
from qutebrowser.utils.usertypes import KeyMode
@@ -54,7 +53,6 @@ class CommandDispatcher:
cmdutils.register() decorators are run, currentWidget() will return None.
Attributes:
- _editor: The ExternalEditor object.
_win_id: The window ID the CommandDispatcher is associated with.
_tabbed_browser: The TabbedBrowser used.
"""
@@ -74,16 +72,16 @@ class CommandDispatcher:
def _count(self):
"""Convenience method to get the widget count."""
- return self._tabbed_browser.count()
+ return self._tabbed_browser.widget.count()
def _set_current_index(self, idx):
"""Convenience method to set the current widget index."""
cmdutils.check_overflow(idx, 'int')
- self._tabbed_browser.setCurrentIndex(idx)
+ self._tabbed_browser.widget.setCurrentIndex(idx)
def _current_index(self):
"""Convenience method to get the current widget index."""
- return self._tabbed_browser.currentIndex()
+ return self._tabbed_browser.widget.currentIndex()
def _current_url(self):
"""Convenience method to get the current url."""
@@ -102,7 +100,7 @@ class CommandDispatcher:
def _current_widget(self):
"""Get the currently active widget from a command."""
- widget = self._tabbed_browser.currentWidget()
+ widget = self._tabbed_browser.widget.currentWidget()
if widget is None:
raise cmdexc.CommandError("No WebView available yet!")
return widget
@@ -148,10 +146,10 @@ class CommandDispatcher:
None if no widget was found.
"""
if count is None:
- return self._tabbed_browser.currentWidget()
+ return self._tabbed_browser.widget.currentWidget()
elif 1 <= count <= self._count():
cmdutils.check_overflow(count + 1, 'int')
- return self._tabbed_browser.widget(count - 1)
+ return self._tabbed_browser.widget.widget(count - 1)
else:
return None
@@ -164,7 +162,7 @@ class CommandDispatcher:
if not show_error:
return
raise cmdexc.CommandError("No last focused tab!")
- idx = self._tabbed_browser.indexOf(tab)
+ idx = self._tabbed_browser.widget.indexOf(tab)
if idx == -1:
raise cmdexc.CommandError("Last focused tab vanished!")
self._set_current_index(idx)
@@ -213,7 +211,7 @@ class CommandDispatcher:
what's configured in 'tabs.select_on_remove'.
count: The tab index to close, or None
"""
- tabbar = self._tabbed_browser.tabBar()
+ tabbar = self._tabbed_browser.widget.tabBar()
selection_override = self._get_selection_override(prev, next_,
opposite)
@@ -265,7 +263,7 @@ class CommandDispatcher:
return
to_pin = not tab.data.pinned
- self._tabbed_browser.set_tab_pinned(tab, to_pin)
+ self._tabbed_browser.widget.set_tab_pinned(tab, to_pin)
@cmdutils.register(instance='command-dispatcher', name='open',
maxsplit=0, scope='window')
@@ -484,7 +482,8 @@ class CommandDispatcher:
"""
cmdutils.check_exclusive((bg, window), 'bw')
curtab = self._current_widget()
- cur_title = self._tabbed_browser.page_title(self._current_index())
+ cur_title = self._tabbed_browser.widget.page_title(
+ self._current_index())
try:
history = curtab.history.serialize()
except browsertab.WebTabError as e:
@@ -500,18 +499,18 @@ class CommandDispatcher:
newtab = new_tabbed_browser.tabopen(background=bg)
new_tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=newtab.win_id)
- idx = new_tabbed_browser.indexOf(newtab)
+ idx = new_tabbed_browser.widget.indexOf(newtab)
- new_tabbed_browser.set_page_title(idx, cur_title)
+ new_tabbed_browser.widget.set_page_title(idx, cur_title)
if config.val.tabs.favicons.show:
- new_tabbed_browser.setTabIcon(idx, curtab.icon())
+ new_tabbed_browser.widget.setTabIcon(idx, curtab.icon())
if config.val.tabs.tabs_are_windows:
- new_tabbed_browser.window().setWindowIcon(curtab.icon())
+ new_tabbed_browser.widget.window().setWindowIcon(curtab.icon())
newtab.data.keep_icon = True
newtab.history.deserialize(history)
newtab.zoom.set_factor(curtab.zoom.factor())
- new_tabbed_browser.set_tab_pinned(newtab, curtab.data.pinned)
+ new_tabbed_browser.widget.set_tab_pinned(newtab, curtab.data.pinned)
return newtab
@cmdutils.register(instance='command-dispatcher', scope='window')
@@ -847,7 +846,7 @@ class CommandDispatcher:
keep: Stay in visual mode after yanking the selection.
"""
if what == 'title':
- s = self._tabbed_browser.page_title(self._current_index())
+ s = self._tabbed_browser.widget.page_title(self._current_index())
elif what == 'domain':
port = self._current_url().port()
s = '{}://{}{}'.format(self._current_url().scheme(),
@@ -959,7 +958,7 @@ class CommandDispatcher:
force: Avoid confirmation for pinned tabs.
"""
cmdutils.check_exclusive((prev, next_), 'pn')
- cur_idx = self._tabbed_browser.currentIndex()
+ cur_idx = self._tabbed_browser.widget.currentIndex()
assert cur_idx != -1
def _to_close(i):
@@ -1076,11 +1075,11 @@ class CommandDispatcher:
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=win_id)
- if not 0 < idx <= tabbed_browser.count():
+ if not 0 < idx <= tabbed_browser.widget.count():
raise cmdexc.CommandError(
"There's no tab with index {}!".format(idx))
- return (tabbed_browser, tabbed_browser.widget(idx-1))
+ return (tabbed_browser, tabbed_browser.widget.widget(idx-1))
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
@@ -1092,24 +1091,26 @@ class CommandDispatcher:
Focuses window if necessary when index is given. If both index and
count are given, use count.
+ With neither index nor count given, open the qute://tabs page.
+
Args:
index: The [win_id/]index of the tab to focus. Or a substring
in which case the closest match will be focused.
count: The tab index to focus, starting with 1.
"""
if count is None and index is None:
- raise cmdexc.CommandError("buffer: Either a count or the argument "
- "index must be specified.")
+ self.openurl('qute://tabs/', tab=True)
+ return
if count is not None:
index = str(count)
tabbed_browser, tab = self._resolve_buffer_index(index)
- window = tabbed_browser.window()
+ window = tabbed_browser.widget.window()
window.activateWindow()
window.raise_()
- tabbed_browser.setCurrentWidget(tab)
+ tabbed_browser.widget.setCurrentWidget(tab)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('index', choices=['last'])
@@ -1193,7 +1194,7 @@ class CommandDispatcher:
cur_idx = self._current_index()
cmdutils.check_overflow(cur_idx, 'int')
cmdutils.check_overflow(new_idx, 'int')
- self._tabbed_browser.tabBar().moveTab(cur_idx, new_idx)
+ self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx)
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0, no_replace_variables=True)
@@ -1277,10 +1278,10 @@ class CommandDispatcher:
idx = self._current_index()
if idx != -1:
- env['QUTE_TITLE'] = self._tabbed_browser.page_title(idx)
+ env['QUTE_TITLE'] = self._tabbed_browser.widget.page_title(idx)
# FIXME:qtwebengine: If tab is None, run_async will fail!
- tab = self._tabbed_browser.currentWidget()
+ tab = self._tabbed_browser.widget.currentWidget()
try:
url = self._tabbed_browser.current_url()
@@ -1638,7 +1639,7 @@ class CommandDispatcher:
ed = editor.ExternalEditor(watch=True, parent=self._tabbed_browser)
ed.file_updated.connect(functools.partial(
- self.on_file_updated, elem))
+ self.on_file_updated, ed, elem))
ed.editing_finished.connect(lambda: mainwindow.raise_window(
objreg.last_focused_window(), alert=False))
ed.edit(text, caret_position)
@@ -1653,7 +1654,7 @@ class CommandDispatcher:
tab = self._current_widget()
tab.elements.find_focused(self._open_editor_cb)
- def on_file_updated(self, elem, text):
+ def on_file_updated(self, ed, elem, text):
"""Write the editor text into the form field and clean up tempfile.
Callback for GUIProcess when the edited text was updated.
@@ -1666,8 +1667,10 @@ class CommandDispatcher:
elem.set_value(text)
except webelem.OrphanedError as e:
message.error('Edited element vanished')
+ ed.backup()
except webelem.Error as e:
- raise cmdexc.CommandError(str(e))
+ message.error(str(e))
+ ed.backup()
@cmdutils.register(instance='command-dispatcher', maxsplit=0,
scope='window')
@@ -1776,10 +1779,10 @@ class CommandDispatcher:
"""
self.set_mark("'")
tab = self._current_widget()
- if tab.search.search_displayed:
- tab.search.clear()
if not text:
+ if tab.search.search_displayed:
+ tab.search.clear()
return
options = {
@@ -2110,15 +2113,13 @@ class CommandDispatcher:
global_: If given, the keys are sent to the qutebrowser UI.
"""
try:
- keyinfos = utils.parse_keystring(keystring)
- except utils.KeyParseError as e:
+ sequence = keyutils.KeySequence.parse(keystring)
+ except keyutils.KeyParseError as e:
raise cmdexc.CommandError(str(e))
- for keyinfo in keyinfos:
- press_event = QKeyEvent(QEvent.KeyPress, keyinfo.key,
- keyinfo.modifiers, keyinfo.text)
- release_event = QKeyEvent(QEvent.KeyRelease, keyinfo.key,
- keyinfo.modifiers, keyinfo.text)
+ for keyinfo in sequence:
+ press_event = keyinfo.to_event(QEvent.KeyPress)
+ release_event = keyinfo.to_event(QEvent.KeyRelease)
if global_:
window = QApplication.focusWindow()
@@ -2218,5 +2219,5 @@ class CommandDispatcher:
pass
return
- window = self._tabbed_browser.window()
+ window = self._tabbed_browser.widget.window()
window.setWindowState(window.windowState() ^ Qt.WindowFullScreen)
diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py
index 4f390b18b..dd112e00a 100644
--- a/qutebrowser/browser/downloads.py
+++ b/qutebrowser/browser/downloads.py
@@ -238,11 +238,14 @@ class FileDownloadTarget(_DownloadTarget):
Attributes:
filename: Filename where the download should be saved.
+ force_overwrite: Whether to overwrite the target without
+ prompting the user.
"""
- def __init__(self, filename):
+ def __init__(self, filename, force_overwrite=False):
# pylint: disable=super-init-not-called
self.filename = filename
+ self.force_overwrite = force_overwrite
def suggested_filename(self):
return os.path.basename(self.filename)
@@ -738,7 +741,8 @@ class AbstractDownloadItem(QObject):
if isinstance(target, FileObjDownloadTarget):
self._set_fileobj(target.fileobj, autoclose=False)
elif isinstance(target, FileDownloadTarget):
- self._set_filename(target.filename)
+ self._set_filename(
+ target.filename, force_overwrite=target.force_overwrite)
elif isinstance(target, OpenFileDownloadTarget):
try:
fobj = temp_download_manager.get_tmpfile(self.basename)
diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py
index fb064f6c1..6879f4cf6 100644
--- a/qutebrowser/browser/greasemonkey.py
+++ b/qutebrowser/browser/greasemonkey.py
@@ -23,13 +23,16 @@ import re
import os
import json
import fnmatch
+import functools
import glob
+import textwrap
import attr
from PyQt5.QtCore import pyqtSignal, QObject, QUrl
-from qutebrowser.utils import log, standarddir, jinja, objreg
+from qutebrowser.utils import log, standarddir, jinja, objreg, utils
from qutebrowser.commands import cmdutils
+from qutebrowser.browser import downloads
def _scripts_dir():
@@ -45,6 +48,7 @@ class GreasemonkeyScript:
self._code = code
self.includes = []
self.excludes = []
+ self.requires = []
self.description = None
self.name = None
self.namespace = None
@@ -66,6 +70,8 @@ class GreasemonkeyScript:
self.run_at = value
elif name == 'noframes':
self.runs_on_sub_frames = False
+ elif name == 'require':
+ self.requires.append(value)
HEADER_REGEX = r'// ==UserScript==|\n+// ==/UserScript==\n'
PROPS_REGEX = r'// @(?P<prop>[^\s]+)\s*(?P<val>.*)'
@@ -93,7 +99,7 @@ class GreasemonkeyScript:
"""Return the processed JavaScript code of this script.
Adorns the source code with GM_* methods for Greasemonkey
- compatibility and wraps it in an IFFE to hide it within a
+ compatibility and wraps it in an IIFE to hide it within a
lexical scope. Note that this means line numbers in your
browser's debugger/inspector will not match up to the line
numbers in the source script directly.
@@ -115,6 +121,14 @@ class GreasemonkeyScript:
'run-at': self.run_at,
})
+ def add_required_script(self, source):
+ """Add the source of a required script to this script."""
+ # The additional source is indented in case it also contains a
+ # metadata block. Because we pass everything at once to
+ # QWebEngineScript and that would parse the first metadata block
+ # found as the valid one.
+ self._code = "\n".join([textwrap.indent(source, " "), self._code])
+
@attr.s
class MatchingScripts(object):
@@ -145,15 +159,24 @@ class GreasemonkeyManager(QObject):
def __init__(self, parent=None):
super().__init__(parent)
+ self._run_start = []
+ self._run_end = []
+ self._run_idle = []
+ self._in_progress_dls = []
+
self.load_scripts()
@cmdutils.register(name='greasemonkey-reload',
instance='greasemonkey')
- def load_scripts(self):
+ def load_scripts(self, force=False):
"""Re-read Greasemonkey scripts from disk.
The scripts are read from a 'greasemonkey' subdirectory in
qutebrowser's data directory (see `:version`).
+
+ Args:
+ force: For any scripts that have required dependencies,
+ re-download them.
"""
self._run_start = []
self._run_end = []
@@ -169,24 +192,115 @@ class GreasemonkeyManager(QObject):
script = GreasemonkeyScript.parse(script_file.read())
if not script.name:
script.name = script_filename
-
- if script.run_at == 'document-start':
- self._run_start.append(script)
- elif script.run_at == 'document-end':
- self._run_end.append(script)
- elif script.run_at == 'document-idle':
- self._run_idle.append(script)
- else:
- if script.run_at:
- log.greasemonkey.warning(
- "Script {} has invalid run-at defined, "
- "defaulting to document-end".format(script_path))
- # Default as per
- # https://wiki.greasespot.net/Metadata_Block#.40run-at
- self._run_end.append(script)
- log.greasemonkey.debug("Loaded script: {}".format(script.name))
+ self.add_script(script, force)
self.scripts_reloaded.emit()
+ def add_script(self, script, force=False):
+ """Add a GreasemonkeyScript to this manager.
+
+ Args:
+ force: Fetch and overwrite any dependancies which are
+ already locally cached.
+ """
+ if script.requires:
+ log.greasemonkey.debug(
+ "Deferring script until requirements are "
+ "fulfilled: {}".format(script.name))
+ self._get_required_scripts(script, force)
+ else:
+ self._add_script(script)
+
+ def _add_script(self, script):
+ if script.run_at == 'document-start':
+ self._run_start.append(script)
+ elif script.run_at == 'document-end':
+ self._run_end.append(script)
+ elif script.run_at == 'document-idle':
+ self._run_idle.append(script)
+ else:
+ if script.run_at:
+ log.greasemonkey.warning("Script {} has invalid run-at "
+ "defined, defaulting to "
+ "document-end"
+ .format(script.name))
+ # Default as per
+ # https://wiki.greasespot.net/Metadata_Block#.40run-at
+ self._run_end.append(script)
+ log.greasemonkey.debug("Loaded script: {}".format(script.name))
+
+ def _required_url_to_file_path(self, url):
+ requires_dir = os.path.join(_scripts_dir(), 'requires')
+ if not os.path.exists(requires_dir):
+ os.mkdir(requires_dir)
+ return os.path.join(requires_dir, utils.sanitize_filename(url))
+
+ def _on_required_download_finished(self, script, download):
+ self._in_progress_dls.remove(download)
+ if not self._add_script_with_requires(script):
+ log.greasemonkey.debug(
+ "Finished download {} for script {} "
+ "but some requirements are still pending"
+ .format(download.basename, script.name))
+
+ def _add_script_with_requires(self, script, quiet=False):
+ """Add a script with pending downloads to this GreasemonkeyManager.
+
+ Specifically a script that has dependancies specified via an
+ `@require` rule.
+
+ Args:
+ script: The GreasemonkeyScript to add.
+ quiet: True to suppress the scripts_reloaded signal after
+ adding `script`.
+ Returns: True if the script was added, False if there are still
+ dependancies being downloaded.
+ """
+ # See if we are still waiting on any required scripts for this one
+ for dl in self._in_progress_dls:
+ if dl.requested_url in script.requires:
+ return False
+
+ # Need to add the required scripts to the IIFE now
+ for url in reversed(script.requires):
+ target_path = self._required_url_to_file_path(url)
+ log.greasemonkey.debug(
+ "Adding required script for {} to IIFE: {}"
+ .format(script.name, url))
+ with open(target_path, encoding='utf8') as f:
+ script.add_required_script(f.read())
+
+ self._add_script(script)
+ if not quiet:
+ self.scripts_reloaded.emit()
+ return True
+
+ def _get_required_scripts(self, script, force=False):
+ required_dls = [(url, self._required_url_to_file_path(url))
+ for url in script.requires]
+ if not force:
+ required_dls = [(url, path) for (url, path) in required_dls
+ if not os.path.exists(path)]
+ if not required_dls:
+ # All the required files exist already
+ self._add_script_with_requires(script, quiet=True)
+ return
+
+ download_manager = objreg.get('qtnetwork-download-manager')
+
+ for url, target_path in required_dls:
+ target = downloads.FileDownloadTarget(target_path,
+ force_overwrite=True)
+ download = download_manager.get(QUrl(url), target=target,
+ auto_remove=True)
+ download.requested_url = url
+ self._in_progress_dls.append(download)
+ if download.successful:
+ self._on_required_download_finished(script, download)
+ else:
+ download.finished.connect(
+ functools.partial(self._on_required_download_finished,
+ script, download))
+
def scripts_for(self, url):
"""Fetch scripts that are registered to run for url.
diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py
index 0390d5d1f..f7bcd713c 100644
--- a/qutebrowser/browser/hints.py
+++ b/qutebrowser/browser/hints.py
@@ -682,7 +682,7 @@ class HintManager(QObject):
"""
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
- tab = tabbed_browser.currentWidget()
+ tab = tabbed_browser.widget.currentWidget()
if tab is None:
raise cmdexc.CommandError("No WebView available yet!")
@@ -909,20 +909,27 @@ class HintManager(QObject):
@cmdutils.register(instance='hintmanager', scope='tab',
modes=[usertypes.KeyMode.hint])
- def follow_hint(self, keystring=None):
+ def follow_hint(self, select=False, keystring=None):
"""Follow a hint.
Args:
+ select: Only select the given hint, don't necessarily follow it.
keystring: The hint to follow, or None.
"""
if keystring is None:
if self._context.to_follow is None:
raise cmdexc.CommandError("No hint to follow")
+ elif select:
+ raise cmdexc.CommandError("Can't use --select without hint.")
else:
keystring = self._context.to_follow
elif keystring not in self._context.labels:
raise cmdexc.CommandError("No hint {}!".format(keystring))
- self._fire(keystring)
+
+ if select:
+ self.handle_partial_key(keystring)
+ else:
+ self._fire(keystring)
@pyqtSlot(usertypes.KeyMode)
def on_mode_left(self, mode):
diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py
index b0053cbf1..16a7f227e 100644
--- a/qutebrowser/browser/mouse.py
+++ b/qutebrowser/browser/mouse.py
@@ -151,8 +151,9 @@ class MouseEventFilter(QObject):
if elem.is_editable():
log.mouse.debug("Clicked editable element!")
- modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
- 'click', only_if_normal=True)
+ if config.val.input.insert_mode.auto_enter:
+ modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
+ 'click', only_if_normal=True)
else:
log.mouse.debug("Clicked non-editable element!")
if config.val.input.insert_mode.auto_leave:
diff --git a/qutebrowser/browser/network/proxy.py b/qutebrowser/browser/network/proxy.py
index 96be78742..d3e25c23c 100644
--- a/qutebrowser/browser/network/proxy.py
+++ b/qutebrowser/browser/network/proxy.py
@@ -34,6 +34,10 @@ def init():
QNetworkProxyFactory.setApplicationProxyFactory(proxy_factory)
+def shutdown():
+ QNetworkProxyFactory.setApplicationProxyFactory(None)
+
+
class ProxyFactory(QNetworkProxyFactory):
"""Factory for proxies to be used by qutebrowser."""
diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py
index cca74cb63..8866f1643 100644
--- a/qutebrowser/browser/qutescheme.py
+++ b/qutebrowser/browser/qutescheme.py
@@ -30,8 +30,10 @@ import time
import textwrap
import mimetypes
import urllib
+import collections
import pkg_resources
+import sip
from PyQt5.QtCore import QUrlQuery, QUrl
import qutebrowser
@@ -201,6 +203,27 @@ def qute_bookmarks(_url):
return 'text/html', html
+@add_handler('tabs')
+def qute_tabs(_url):
+ """Handler for qute://tabs. Display information about all open tabs."""
+ tabs = collections.defaultdict(list)
+ for win_id, window in objreg.window_registry.items():
+ if sip.isdeleted(window):
+ continue
+ tabbed_browser = objreg.get('tabbed-browser',
+ scope='window',
+ window=win_id)
+ for tab in tabbed_browser.widgets():
+ if tab.url() not in [QUrl("qute://tabs/"), QUrl("qute://tabs")]:
+ urlstr = tab.url().toDisplayString()
+ tabs[str(win_id)].append((tab.title(), urlstr))
+
+ html = jinja.render('tabs.html',
+ title='Tabs',
+ tab_list_by_window=tabs)
+ return 'text/html', html
+
+
def history_data(start_time, offset=None):
"""Return history data.
@@ -240,8 +263,6 @@ def qute_history(url):
return 'text/html', json.dumps(history_data(start_time, offset))
else:
- if not config.val.content.javascript.enabled:
- return 'text/plain', b'JavaScript is required for qute://history'
return 'text/html', jinja.render(
'history.html',
title='History',
diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py
index d82b741e5..238fdc1cc 100644
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -74,14 +74,15 @@ def authentication_required(url, authenticator, abort_on):
return answer
-def javascript_confirm(url, js_msg, abort_on):
+def javascript_confirm(url, js_msg, abort_on, *, escape_msg=True):
"""Display a javascript confirm prompt."""
log.js.debug("confirm: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
raise CallSuper
+ js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = 'From <b>{}</b>:<br/>{}'.format(html.escape(url.toDisplayString()),
- html.escape(js_msg))
+ js_msg)
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
ans = message.ask('Javascript confirm', msg,
mode=usertypes.PromptMode.yesno,
@@ -89,7 +90,7 @@ def javascript_confirm(url, js_msg, abort_on):
return bool(ans)
-def javascript_prompt(url, js_msg, default, abort_on):
+def javascript_prompt(url, js_msg, default, abort_on, *, escape_msg=True):
"""Display a javascript prompt."""
log.js.debug("prompt: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
@@ -97,8 +98,9 @@ def javascript_prompt(url, js_msg, default, abort_on):
if not config.val.content.javascript.prompt:
return (False, "")
+ js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = '<b>{}</b> asks:<br/>{}'.format(html.escape(url.toDisplayString()),
- html.escape(js_msg))
+ js_msg)
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
answer = message.ask('Javascript prompt', msg,
mode=usertypes.PromptMode.text,
@@ -111,7 +113,7 @@ def javascript_prompt(url, js_msg, default, abort_on):
return (True, answer)
-def javascript_alert(url, js_msg, abort_on):
+def javascript_alert(url, js_msg, abort_on, *, escape_msg=True):
"""Display a javascript alert."""
log.js.debug("alert: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
@@ -120,8 +122,9 @@ def javascript_alert(url, js_msg, abort_on):
if not config.val.content.javascript.alert:
return
+ js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = 'From <b>{}</b>:<br/>{}'.format(html.escape(url.toDisplayString()),
- html.escape(js_msg))
+ js_msg)
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
message.ask('Javascript alert', msg, mode=usertypes.PromptMode.alert,
abort_on=abort_on, url=urlstr)
diff --git a/qutebrowser/browser/signalfilter.py b/qutebrowser/browser/signalfilter.py
index 663aa67e7..7cc46abdb 100644
--- a/qutebrowser/browser/signalfilter.py
+++ b/qutebrowser/browser/signalfilter.py
@@ -76,11 +76,11 @@ class SignalFilter(QObject):
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
try:
- tabidx = tabbed_browser.indexOf(tab)
+ tabidx = tabbed_browser.widget.indexOf(tab)
except RuntimeError:
# The tab has been deleted already
return
- if tabidx == tabbed_browser.currentIndex():
+ if tabidx == tabbed_browser.widget.currentIndex():
if log_signal:
log.signals.debug("emitting: {} (tab {})".format(
debug.dbg_signal(signal, args), tabidx))
diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py
index d2f563bb6..0a0dfb4f2 100644
--- a/qutebrowser/browser/urlmarks.py
+++ b/qutebrowser/browser/urlmarks.py
@@ -280,7 +280,7 @@ class BookmarkManager(UrlMarkManager):
if urlstr in self.marks:
if toggle:
- del self.marks[urlstr]
+ self.delete(urlstr)
return False
else:
raise AlreadyExistsError("Bookmark already exists!")
diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py
index 122e7d031..dee21c2d6 100644
--- a/qutebrowser/browser/webelem.py
+++ b/qutebrowser/browser/webelem.py
@@ -41,8 +41,8 @@ Group = enum.Enum('Group', ['all', 'links', 'images', 'url', 'inputs'])
SELECTORS = {
Group.all: ('a, area, textarea, select, input:not([type=hidden]), button, '
- 'frame, iframe, link, [onclick], [onmousedown], [role=link], '
- '[role=option], [role=button], img, '
+ 'frame, iframe, link, summary, [onclick], [onmousedown], '
+ '[role=link], [role=option], [role=button], img, '
# Angular 1 selectors
'[ng-click], [ngClick], [data-ng-click], [x-ng-click]'),
Group.links: 'a[href], area[href], link[href], [role=link][href]',
@@ -411,8 +411,9 @@ class AbstractWebElement(collections.abc.MutableMapping):
elif self.is_editable(strict=True):
log.webelem.debug("Clicking via JS focus()")
self._click_editable(click_target)
- modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
- 'clicking input')
+ if config.val.input.insert_mode.auto_enter:
+ modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
+ 'clicking input')
else:
self._click_fake_event(click_target)
elif click_target in [usertypes.ClickTarget.tab,
diff --git a/qutebrowser/browser/webengine/webengineinspector.py b/qutebrowser/browser/webengine/webengineinspector.py
index b2a6ebb1e..9200e3eb3 100644
--- a/qutebrowser/browser/webengine/webengineinspector.py
+++ b/qutebrowser/browser/webengine/webengineinspector.py
@@ -43,8 +43,8 @@ class WebEngineInspector(inspector.AbstractWebInspector):
port = int(os.environ['QTWEBENGINE_REMOTE_DEBUGGING'])
except KeyError:
raise inspector.WebInspectorError(
- "Debugging is not enabled. See 'qutebrowser --help' for "
- "details.")
+ "QtWebEngine inspector is not enabled. See "
+ "'qutebrowser --help' for details.")
url = QUrl('http://localhost:{}/'.format(port))
self._widget.load(url)
self.show()
diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py
index 607499401..46bfcab59 100644
--- a/qutebrowser/browser/webengine/webenginesettings.py
+++ b/qutebrowser/browser/webengine/webenginesettings.py
@@ -17,9 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# We get various "abstract but not overridden" warnings
-# pylint: disable=abstract-method
-
"""Bridge from QWebEngineSettings to our own settings.
Module attributes:
@@ -44,116 +41,132 @@ from qutebrowser.utils import (utils, standarddir, javascript, qtutils,
default_profile = None
# The QWebEngineProfile used for private (off-the-record) windows
private_profile = None
+# The global WebEngineSettings object
+global_settings = None
-class Base(websettings.Base):
-
- """Base settings class with appropriate _get_global_settings."""
-
- def _get_global_settings(self):
- return [default_profile.settings(), private_profile.settings()]
-
-
-class Attribute(Base, websettings.Attribute):
-
- """A setting set via QWebEngineSettings::setAttribute."""
-
- ENUM_BASE = QWebEngineSettings
-
-
-class Setter(Base, websettings.Setter):
+class _SettingsWrapper:
- """A setting set via a QWebEngineSettings setter method."""
+ """Expose a QWebEngineSettings interface which acts on all profiles.
- pass
-
-
-class FontFamilySetter(Base, websettings.FontFamilySetter):
-
- """A setter for a font family.
-
- Gets the default value from QFont.
+ For read operations, the default profile value is always used.
"""
- def __init__(self, font):
- # Mapping from WebEngineSettings::initDefaults in
- # qtwebengine/src/core/web_engine_settings.cpp
- font_to_qfont = {
- QWebEngineSettings.StandardFont: QFont.Serif,
- QWebEngineSettings.FixedFont: QFont.Monospace,
- QWebEngineSettings.SerifFont: QFont.Serif,
- QWebEngineSettings.SansSerifFont: QFont.SansSerif,
- QWebEngineSettings.CursiveFont: QFont.Cursive,
- QWebEngineSettings.FantasyFont: QFont.Fantasy,
- }
- super().__init__(setter=QWebEngineSettings.setFontFamily, font=font,
- qfont=font_to_qfont[font])
-
-
-class DefaultProfileSetter(websettings.Base):
-
- """A setting set on the QWebEngineProfile."""
-
- def __init__(self, setter, converter=None, default=websettings.UNSET):
- super().__init__(default)
- self._setter = setter
- self._converter = converter
-
- def __repr__(self):
- return utils.get_repr(self, setter=self._setter, constructor=True)
-
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with "
- "DefaultProfileSetters!")
-
- setter = getattr(default_profile, self._setter)
- if self._converter is not None:
- value = self._converter(value)
-
- setter(value)
-
-
-class PersistentCookiePolicy(DefaultProfileSetter):
-
- """The content.cookies.store setting is different from other settings."""
-
- def __init__(self):
- super().__init__('setPersistentCookiesPolicy')
-
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with "
- "PersistentCookiePolicy!")
- setter = getattr(QWebEngineProfile.defaultProfile(), self._setter)
- setter(
- QWebEngineProfile.AllowPersistentCookies if value else
- QWebEngineProfile.NoPersistentCookies
- )
-
-
-class DictionaryLanguageSetter(DefaultProfileSetter):
-
- """Sets paths to dictionary files based on language codes."""
-
def __init__(self):
- super().__init__('setSpellCheckLanguages', default=[])
-
- def _find_installed(self, code):
- local_filename = spell.local_filename(code)
- if not local_filename:
- message.warning(
- "Language {} is not installed - see scripts/dictcli.py "
- "in qutebrowser's sources".format(code))
- return local_filename
+ self._settings = [default_profile.settings(),
+ private_profile.settings()]
+
+ def setAttribute(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setAttribute(*args, **kwargs)
+
+ def setFontFamily(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setFontFamily(*args, **kwargs)
+
+ def setFontSize(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setFontSize(*args, **kwargs)
+
+ def setDefaultTextEncoding(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setDefaultTextEncoding(*args, **kwargs)
+
+ def testAttribute(self, *args, **kwargs):
+ return self._settings[0].testAttribute(*args, **kwargs)
+
+ def fontSize(self, *args, **kwargs):
+ return self._settings[0].fontSize(*args, **kwargs)
+
+ def fontFamily(self, *args, **kwargs):
+ return self._settings[0].fontFamily(*args, **kwargs)
+
+ def defaultTextEncoding(self, *args, **kwargs):
+ return self._settings[0].defaultTextEncoding(*args, **kwargs)
+
+
+class WebEngineSettings(websettings.AbstractSettings):
+
+ """A wrapper for the config for QWebEngineSettings."""
+
+ _ATTRIBUTES = {
+ 'content.xss_auditing':
+ [QWebEngineSettings.XSSAuditingEnabled],
+ 'content.images':
+ [QWebEngineSettings.AutoLoadImages],
+ 'content.javascript.enabled':
+ [QWebEngineSettings.JavascriptEnabled],
+ 'content.javascript.can_open_tabs_automatically':
+ [QWebEngineSettings.JavascriptCanOpenWindows],
+ 'content.javascript.can_access_clipboard':
+ [QWebEngineSettings.JavascriptCanAccessClipboard],
+ 'content.plugins':
+ [QWebEngineSettings.PluginsEnabled],
+ 'content.hyperlink_auditing':
+ [QWebEngineSettings.HyperlinkAuditingEnabled],
+ 'content.local_content_can_access_remote_urls':
+ [QWebEngineSettings.LocalContentCanAccessRemoteUrls],
+ 'content.local_content_can_access_file_urls':
+ [QWebEngineSettings.LocalContentCanAccessFileUrls],
+ 'content.webgl':
+ [QWebEngineSettings.WebGLEnabled],
+ 'content.local_storage':
+ [QWebEngineSettings.LocalStorageEnabled],
+
+ 'input.spatial_navigation':
+ [QWebEngineSettings.SpatialNavigationEnabled],
+ 'input.links_included_in_focus_chain':
+ [QWebEngineSettings.LinksIncludedInFocusChain],
+
+ 'scrolling.smooth':
+ [QWebEngineSettings.ScrollAnimatorEnabled],
+ }
+
+ _FONT_SIZES = {
+ 'fonts.web.size.minimum':
+ QWebEngineSettings.MinimumFontSize,
+ 'fonts.web.size.minimum_logical':
+ QWebEngineSettings.MinimumLogicalFontSize,
+ 'fonts.web.size.default':
+ QWebEngineSettings.DefaultFontSize,
+ 'fonts.web.size.default_fixed':
+ QWebEngineSettings.DefaultFixedFontSize,
+ }
+
+ _FONT_FAMILIES = {
+ 'fonts.web.family.standard': QWebEngineSettings.StandardFont,
+ 'fonts.web.family.fixed': QWebEngineSettings.FixedFont,
+ 'fonts.web.family.serif': QWebEngineSettings.SerifFont,
+ 'fonts.web.family.sans_serif': QWebEngineSettings.SansSerifFont,
+ 'fonts.web.family.cursive': QWebEngineSettings.CursiveFont,
+ 'fonts.web.family.fantasy': QWebEngineSettings.FantasyFont,
+ }
+
+ # Mapping from WebEngineSettings::initDefaults in
+ # qtwebengine/src/core/web_engine_settings.cpp
+ _FONT_TO_QFONT = {
+ QWebEngineSettings.StandardFont: QFont.Serif,
+ QWebEngineSettings.FixedFont: QFont.Monospace,
+ QWebEngineSettings.SerifFont: QFont.Serif,
+ QWebEngineSettings.SansSerifFont: QFont.SansSerif,
+ QWebEngineSettings.CursiveFont: QFont.Cursive,
+ QWebEngineSettings.FantasyFont: QFont.Fantasy,
+ }
+
+ def __init__(self, settings):
+ super().__init__(settings)
+ # Attributes which don't exist in all Qt versions.
+ new_attributes = {
+ # Qt 5.8
+ 'content.print_element_backgrounds': 'PrintElementBackgrounds',
+ }
+ for name, attribute in new_attributes.items():
+ try:
+ value = getattr(QWebEngineSettings, attribute)
+ except AttributeError:
+ continue
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with "
- "DictionaryLanguageSetter!")
- filenames = [self._find_installed(code) for code in value]
- log.config.debug("Found dicts: {}".format(filenames))
- super()._set([f for f in filenames if f], settings)
+ self._ATTRIBUTES[name] = [value]
def _init_stylesheet(profile):
@@ -210,9 +223,48 @@ def _set_http_headers(profile):
profile.setHttpAcceptLanguage(accept_language)
+def _set_http_cache_size(profile):
+ """Initialize the HTTP cache size for the given profile."""
+ size = config.val.content.cache.size
+ if size is None:
+ size = 0
+ else:
+ size = qtutils.check_overflow(size, 'int', fatal=False)
+
+ # 0: automatically managed by QtWebEngine
+ profile.setHttpCacheMaximumSize(size)
+
+
+def _set_persistent_cookie_policy(profile):
+ """Set the HTTP Cookie size for the given profile."""
+ if config.val.content.cookies.store:
+ value = QWebEngineProfile.AllowPersistentCookies
+ else:
+ value = QWebEngineProfile.NoPersistentCookies
+ profile.setPersistentCookiesPolicy(value)
+
+
+def _set_dictionary_language(profile, warn=True):
+ filenames = []
+ for code in config.val.spellcheck.languages or []:
+ local_filename = spell.local_filename(code)
+ if not local_filename:
+ if warn:
+ message.warning(
+ "Language {} is not installed - see scripts/dictcli.py "
+ "in qutebrowser's sources".format(code))
+ continue
+
+ filenames.append(local_filename)
+
+ log.config.debug("Found dicts: {}".format(filenames))
+ profile.setSpellCheckLanguages(filenames)
+
+
def _update_settings(option):
"""Update global settings when qwebsettings changed."""
- websettings.update_mappings(MAPPINGS, option)
+ global_settings.update_setting(option)
+
if option in ['scrolling.bar', 'content.user_stylesheets']:
_init_stylesheet(default_profile)
_init_stylesheet(private_profile)
@@ -221,27 +273,46 @@ def _update_settings(option):
'content.headers.accept_language']:
_set_http_headers(default_profile)
_set_http_headers(private_profile)
+ elif option == 'content.cache.size':
+ _set_http_cache_size(default_profile)
+ _set_http_cache_size(private_profile)
+ elif (option == 'content.cookies.store' and
+ # https://bugreports.qt.io/browse/QTBUG-58650
+ qtutils.version_check('5.9', compiled=False)):
+ _set_persistent_cookie_policy(default_profile)
+ # We're not touching the private profile's cookie policy.
+ elif option == 'spellcheck.languages':
+ _set_dictionary_language(default_profile)
+ _set_dictionary_language(private_profile, warn=False)
+
+
+def _init_profile(profile):
+ """Init the given profile."""
+ _init_stylesheet(profile)
+ _set_http_headers(profile)
+ _set_http_cache_size(profile)
+ profile.settings().setAttribute(
+ QWebEngineSettings.FullScreenSupportEnabled, True)
+ if qtutils.version_check('5.8'):
+ profile.setSpellCheckEnabled(True)
+ _set_dictionary_language(profile)
def _init_profiles():
"""Init the two used QWebEngineProfiles."""
global default_profile, private_profile
+
default_profile = QWebEngineProfile.defaultProfile()
default_profile.setCachePath(
os.path.join(standarddir.cache(), 'webengine'))
default_profile.setPersistentStoragePath(
os.path.join(standarddir.data(), 'webengine'))
- _init_stylesheet(default_profile)
- _set_http_headers(default_profile)
+ _init_profile(default_profile)
+ _set_persistent_cookie_policy(default_profile)
private_profile = QWebEngineProfile()
assert private_profile.isOffTheRecord()
- _init_stylesheet(private_profile)
- _set_http_headers(private_profile)
-
- if qtutils.version_check('5.8'):
- default_profile.setSpellCheckEnabled(True)
- private_profile.setSpellCheckEnabled(True)
+ _init_profile(private_profile)
def inject_userscripts():
@@ -287,111 +358,12 @@ def init(args):
os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port())
_init_profiles()
-
- # We need to do this here as a WORKAROUND for
- # https://bugreports.qt.io/browse/QTBUG-58650
- if not qtutils.version_check('5.9', compiled=False):
- PersistentCookiePolicy().set(config.val.content.cookies.store)
- Attribute(QWebEngineSettings.FullScreenSupportEnabled).set(True)
-
- websettings.init_mappings(MAPPINGS)
config.instance.changed.connect(_update_settings)
-
-def shutdown():
- # FIXME:qtwebengine do we need to do something for a clean shutdown here?
- pass
+ global global_settings
+ global_settings = WebEngineSettings(_SettingsWrapper())
+ global_settings.init_settings()
-# Missing QtWebEngine attributes:
-# - ScreenCaptureEnabled
-# - Accelerated2dCanvasEnabled
-# - AutoLoadIconsForPage
-# - TouchIconsEnabled
-# - FocusOnNavigationEnabled (5.8)
-# - AllowRunningInsecureContent (5.8)
-#
-# Missing QtWebEngine fonts:
-# - PictographFont
-
-
-MAPPINGS = {
- 'content.images':
- Attribute(QWebEngineSettings.AutoLoadImages),
- 'content.javascript.enabled':
- Attribute(QWebEngineSettings.JavascriptEnabled),
- 'content.javascript.can_open_tabs_automatically':
- Attribute(QWebEngineSettings.JavascriptCanOpenWindows),
- 'content.javascript.can_access_clipboard':
- Attribute(QWebEngineSettings.JavascriptCanAccessClipboard),
- 'content.plugins':
- Attribute(QWebEngineSettings.PluginsEnabled),
- 'content.hyperlink_auditing':
- Attribute(QWebEngineSettings.HyperlinkAuditingEnabled),
- 'content.local_content_can_access_remote_urls':
- Attribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls),
- 'content.local_content_can_access_file_urls':
- Attribute(QWebEngineSettings.LocalContentCanAccessFileUrls),
- 'content.webgl':
- Attribute(QWebEngineSettings.WebGLEnabled),
- 'content.local_storage':
- Attribute(QWebEngineSettings.LocalStorageEnabled),
- 'content.cache.size':
- # 0: automatically managed by QtWebEngine
- DefaultProfileSetter('setHttpCacheMaximumSize', default=0,
- converter=lambda val:
- qtutils.check_overflow(val, 'int', fatal=False)),
- 'content.xss_auditing':
- Attribute(QWebEngineSettings.XSSAuditingEnabled),
- 'content.default_encoding':
- Setter(QWebEngineSettings.setDefaultTextEncoding),
-
- 'input.spatial_navigation':
- Attribute(QWebEngineSettings.SpatialNavigationEnabled),
- 'input.links_included_in_focus_chain':
- Attribute(QWebEngineSettings.LinksIncludedInFocusChain),
-
- 'fonts.web.family.standard':
- FontFamilySetter(QWebEngineSettings.StandardFont),
- 'fonts.web.family.fixed':
- FontFamilySetter(QWebEngineSettings.FixedFont),
- 'fonts.web.family.serif':
- FontFamilySetter(QWebEngineSettings.SerifFont),
- 'fonts.web.family.sans_serif':
- FontFamilySetter(QWebEngineSettings.SansSerifFont),
- 'fonts.web.family.cursive':
- FontFamilySetter(QWebEngineSettings.CursiveFont),
- 'fonts.web.family.fantasy':
- FontFamilySetter(QWebEngineSettings.FantasyFont),
- 'fonts.web.size.minimum':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.MinimumFontSize]),
- 'fonts.web.size.minimum_logical':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.MinimumLogicalFontSize]),
- 'fonts.web.size.default':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.DefaultFontSize]),
- 'fonts.web.size.default_fixed':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.DefaultFixedFontSize]),
-
- 'scrolling.smooth':
- Attribute(QWebEngineSettings.ScrollAnimatorEnabled),
-}
-
-try:
- MAPPINGS['content.print_element_backgrounds'] = Attribute(
- QWebEngineSettings.PrintElementBackgrounds)
-except AttributeError:
- # Added in Qt 5.8
+def shutdown():
pass
-
-
-if qtutils.version_check('5.8'):
- MAPPINGS['spellcheck.languages'] = DictionaryLanguageSetter()
-
-
-if qtutils.version_check('5.9', compiled=False):
- # https://bugreports.qt.io/browse/QTBUG-58650
- MAPPINGS['content.cookies.store'] = PersistentCookiePolicy()
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index ed6697f03..87adbc81e 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -22,16 +22,18 @@
import math
import functools
import sys
+import re
import html as html_utils
import sip
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QEvent, QPoint, QPointF,
- QUrl)
-from PyQt5.QtGui import QKeyEvent
+ QUrl, QTimer)
+from PyQt5.QtGui import QKeyEvent, QIcon
from PyQt5.QtNetwork import QAuthenticator
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript
+from qutebrowser.config import configdata
from qutebrowser.browser import browsertab, mouse, shared
from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory,
interceptor, webenginequtescheme,
@@ -183,6 +185,12 @@ class WebEngineSearch(browsertab.AbstractSearch):
def search(self, text, *, ignore_case='never', reverse=False,
result_cb=None):
+ # Don't go to next entry on duplicate search
+ if self.text == text and self.search_displayed:
+ log.webview.debug("Ignoring duplicate search request"
+ " for {}".format(text))
+ return
+
self.text = text
self._flags = QWebEnginePage.FindFlags(0)
if self._is_case_sensitive(ignore_case):
@@ -218,12 +226,21 @@ class WebEngineCaret(browsertab.AbstractCaret):
if mode != usertypes.KeyMode.caret:
return
+ if self._tab.search.search_displayed:
+ # We are currently in search mode.
+ # convert the search to a blue selection so we can operate on it
+ # https://bugreports.qt.io/browse/QTBUG-60673
+ self._tab.search.clear()
+
self._tab.run_js_async(
javascript.assemble('caret', 'setPlatform', sys.platform))
self._js_call('setInitialCursor')
@pyqtSlot(usertypes.KeyMode)
- def _on_mode_left(self):
+ def _on_mode_left(self, mode):
+ if mode != usertypes.KeyMode.caret:
+ return
+
self.drop_selection()
self._js_call('disableCaret')
@@ -470,7 +487,8 @@ class WebEngineHistory(browsertab.AbstractHistory):
return self._history.itemAt(i)
def _go_to_item(self, item):
- return self._history.goToItem(item)
+ self._tab.predicted_navigation.emit(item.url())
+ self._history.goToItem(item)
def serialize(self):
if not qtutils.version_check('5.9', compiled=False):
@@ -488,6 +506,9 @@ class WebEngineHistory(browsertab.AbstractHistory):
return qtutils.deserialize(data, self._history)
def load_items(self, items):
+ if items:
+ self._tab.predicted_navigation.emit(items[-1].url)
+
stream, _data, cur_data = tabhistory.serialize(items)
qtutils.deserialize_stream(stream, self._history)
@@ -604,12 +625,15 @@ class WebEngineTab(browsertab.AbstractTab):
self.printing = WebEnginePrinting()
self.elements = WebEngineElements(tab=self)
self.action = WebEngineAction(tab=self)
+ # We're assigning settings in _set_widget
+ self.settings = webenginesettings.WebEngineSettings(settings=None)
self._set_widget(widget)
self._connect_signals()
self.backend = usertypes.Backend.QtWebEngine
self._init_js()
self._child_event_filter = None
self._saved_zoom = None
+ self._reload_url = None
def _init_js(self):
js_code = '\n'.join([
@@ -648,9 +672,15 @@ class WebEngineTab(browsertab.AbstractTab):
self.zoom.set_factor(self._saved_zoom)
self._saved_zoom = None
- def openurl(self, url):
+ def openurl(self, url, *, predict=True):
+ """Open the given URL in this tab.
+
+ Arguments:
+ url: The QUrl to open.
+ predict: If set to False, predicted_navigation is not emitted.
+ """
self._saved_zoom = self.zoom.factor()
- self._openurl_prepare(url)
+ self._openurl_prepare(url, predict=predict)
self._widget.load(url)
def url(self, requested=False):
@@ -682,10 +712,6 @@ class WebEngineTab(browsertab.AbstractTab):
def shutdown(self):
self.shutting_down.emit()
self.action.exit_fullscreen()
- if qtutils.version_check('5.8', exact=True, compiled=False):
- # WORKAROUND for
- # https://bugreports.qt.io/browse/QTBUG-58563
- self.search.clear()
self._widget.shutdown()
def reload(self, *, force=False):
@@ -728,6 +754,16 @@ class WebEngineTab(browsertab.AbstractTab):
self.send_event(press_evt)
self.send_event(release_evt)
+ def _show_error_page(self, url, error):
+ """Show an error page in the tab."""
+ log.misc.debug("Showing error page for {}".format(error))
+ url_string = url.toDisplayString()
+ error_page = jinja.render(
+ 'error.html',
+ title="Error loading page: {}".format(url_string),
+ url=url_string, error=error)
+ self.set_html(error_page)
+
@pyqtSlot()
def _on_history_trigger(self):
try:
@@ -776,13 +812,7 @@ class WebEngineTab(browsertab.AbstractTab):
sip.assign(authenticator, QAuthenticator())
# pylint: enable=no-member, useless-suppression
except AttributeError:
- url_string = url.toDisplayString()
- error_page = jinja.render(
- 'error.html',
- title="Error loading page: {}".format(url_string),
- url=url_string, error="Proxy authentication required",
- icon='')
- self.set_html(error_page)
+ self._show_error_page(url, "Proxy authentication required")
@pyqtSlot(QUrl, 'QAuthenticator*')
def _on_authentication_required(self, url, authenticator):
@@ -802,12 +832,7 @@ class WebEngineTab(browsertab.AbstractTab):
except AttributeError:
# WORKAROUND for
# https://www.riverbankcomputing.com/pipermail/pyqt/2016-December/038400.html
- url_string = url.toDisplayString()
- error_page = jinja.render(
- 'error.html',
- title="Error loading page: {}".format(url_string),
- url=url_string, error="Authentication required")
- self.set_html(error_page)
+ self._show_error_page(url, "Authentication required")
@pyqtSlot('QWebEngineFullScreenRequest')
def _on_fullscreen_requested(self, request):
@@ -872,6 +897,74 @@ class WebEngineTab(browsertab.AbstractTab):
if not ok:
self._load_finished_fake.emit(False)
+ def _error_page_workaround(self, html):
+ """Check if we're displaying a Chromium error page.
+
+ This gets only called if we got loadFinished(False) without JavaScript,
+ so we can display at least some error page.
+
+ WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643
+ Needs to check the page content as a WORKAROUND for
+ https://bugreports.qt.io/browse/QTBUG-66661
+ """
+ match = re.search(r'"errorCode":"([^"]*)"', html)
+ if match is None:
+ return
+ self._show_error_page(self.url(), error=match.group(1))
+
+ @pyqtSlot(bool)
+ def _on_load_finished(self, ok):
+ """Display a static error page if JavaScript is disabled."""
+ super()._on_load_finished(ok)
+ js_enabled = self.settings.test_attribute('content.javascript.enabled')
+ if not ok and not js_enabled:
+ self.dump_async(self._error_page_workaround)
+
+ if ok and self._reload_url is not None:
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
+ log.config.debug(
+ "Loading {} again because of config change".format(
+ self._reload_url.toDisplayString()))
+ QTimer.singleShot(100, lambda url=self._reload_url:
+ self.openurl(url, predict=False))
+ self._reload_url = None
+
+ if not qtutils.version_check('5.10', compiled=False):
+ # We can't do this when we have the loadFinished workaround as that
+ # sometimes clears icons without loading a new page.
+ # In general, this is handled by Qt, but when loading takes long,
+ # the old icon is still displayed.
+ self.icon_changed.emit(QIcon())
+
+ @pyqtSlot(QUrl)
+ def _on_predicted_navigation(self, url):
+ """If we know we're going to visit an URL soon, change the settings."""
+ super()._on_predicted_navigation(url)
+ self.settings.update_for_url(url)
+
+ @pyqtSlot(usertypes.NavigationRequest)
+ def _on_navigation_request(self, navigation):
+ super()._on_navigation_request(navigation)
+ if not navigation.accepted or not navigation.is_main_frame:
+ return
+
+ needs_reload = {
+ 'content.plugins',
+ 'content.javascript.enabled',
+ 'content.javascript.can_access_clipboard',
+ 'content.javascript.can_access_clipboard',
+ 'content.print_element_backgrounds',
+ 'input.spatial_navigation',
+ 'input.spatial_navigation',
+ }
+ assert needs_reload.issubset(configdata.DATA)
+
+ changed = self.settings.update_for_url(navigation.url)
+ if (changed & needs_reload and navigation.navigation_type !=
+ navigation.Type.link_clicked):
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
+ self._reload_url = navigation.url
+
def _connect_signals(self):
view = self._widget
page = view.page()
@@ -886,6 +979,7 @@ class WebEngineTab(browsertab.AbstractTab):
self._on_proxy_authentication_required)
page.fullScreenRequested.connect(self._on_fullscreen_requested)
page.contentsSizeChanged.connect(self.contents_size_changed)
+ page.navigation_request.connect(self._on_navigation_request)
view.titleChanged.connect(self.title_changed)
view.urlChanged.connect(self._on_url_changed)
@@ -906,5 +1000,7 @@ class WebEngineTab(browsertab.AbstractTab):
page.loadFinished.connect(self._restore_zoom)
page.loadFinished.connect(self._on_load_finished)
+ self.predicted_navigation.connect(self._on_predicted_navigation)
+
def event_target(self):
return self._widget.focusProxy()
diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py
index 1b3c15f9e..70cd11e0d 100644
--- a/qutebrowser/browser/webengine/webview.py
+++ b/qutebrowser/browser/webengine/webview.py
@@ -29,8 +29,7 @@ from PyQt5.QtWebEngineWidgets import (QWebEngineView, QWebEnginePage,
from qutebrowser.browser import shared
from qutebrowser.browser.webengine import certificateerror, webenginesettings
from qutebrowser.config import config
-from qutebrowser.utils import (log, debug, usertypes, jinja, urlutils, message,
- objreg, qtutils)
+from qutebrowser.utils import log, debug, usertypes, jinja, objreg, qtutils
class WebEngineView(QWebEngineView):
@@ -124,10 +123,12 @@ class WebEnginePage(QWebEnginePage):
Signals:
certificate_error: Emitted on certificate errors.
shutting_down: Emitted when the page is shutting down.
+ navigation_request: Emitted on acceptNavigationRequest.
"""
certificate_error = pyqtSignal()
shutting_down = pyqtSignal()
+ navigation_request = pyqtSignal(usertypes.NavigationRequest)
def __init__(self, *, theme_color, profile, parent=None):
super().__init__(profile, parent)
@@ -242,10 +243,12 @@ class WebEnginePage(QWebEnginePage):
"""Override javaScriptConfirm to use qutebrowser prompts."""
if self._is_shutting_down:
return False
+ escape_msg = qtutils.version_check('5.11', compiled=False)
try:
return shared.javascript_confirm(url, js_msg,
abort_on=[self.loadStarted,
- self.shutting_down])
+ self.shutting_down],
+ escape_msg=escape_msg)
except shared.CallSuper:
return super().javaScriptConfirm(url, js_msg)
@@ -255,12 +258,14 @@ class WebEnginePage(QWebEnginePage):
# https://www.riverbankcomputing.com/pipermail/pyqt/2016-November/038293.html
def javaScriptPrompt(self, url, js_msg, default):
"""Override javaScriptPrompt to use qutebrowser prompts."""
+ escape_msg = qtutils.version_check('5.11', compiled=False)
if self._is_shutting_down:
return (False, "")
try:
return shared.javascript_prompt(url, js_msg, default,
abort_on=[self.loadStarted,
- self.shutting_down])
+ self.shutting_down],
+ escape_msg=escape_msg)
except shared.CallSuper:
return super().javaScriptPrompt(url, js_msg, default)
@@ -268,10 +273,12 @@ class WebEnginePage(QWebEnginePage):
"""Override javaScriptAlert to use qutebrowser prompts."""
if self._is_shutting_down:
return
+ escape_msg = qtutils.version_check('5.11', compiled=False)
try:
shared.javascript_alert(url, js_msg,
abort_on=[self.loadStarted,
- self.shutting_down])
+ self.shutting_down],
+ escape_msg=escape_msg)
except shared.CallSuper:
super().javaScriptAlert(url, js_msg)
@@ -288,21 +295,26 @@ class WebEnginePage(QWebEnginePage):
url: QUrl,
typ: QWebEnginePage.NavigationType,
is_main_frame: bool):
- """Override acceptNavigationRequest to handle clicked links.
-
- This only show an error on invalid links - everything else is handled
- in createWindow.
- """
- log.webview.debug("navigation request: url {}, type {}, is_main_frame "
- "{}".format(url.toDisplayString(),
- debug.qenum_key(QWebEnginePage, typ),
- is_main_frame))
- if (typ == QWebEnginePage.NavigationTypeLinkClicked and
- not url.isValid()):
- msg = urlutils.get_errstring(url, "Invalid link clicked")
- message.error(msg)
- return False
- return True
+ """Override acceptNavigationRequest to forward it to the tab API."""
+ type_map = {
+ QWebEnginePage.NavigationTypeLinkClicked:
+ usertypes.NavigationRequest.Type.link_clicked,
+ QWebEnginePage.NavigationTypeTyped:
+ usertypes.NavigationRequest.Type.typed,
+ QWebEnginePage.NavigationTypeFormSubmitted:
+ usertypes.NavigationRequest.Type.form_submitted,
+ QWebEnginePage.NavigationTypeBackForward:
+ usertypes.NavigationRequest.Type.back_forward,
+ QWebEnginePage.NavigationTypeReload:
+ usertypes.NavigationRequest.Type.reloaded,
+ QWebEnginePage.NavigationTypeOther:
+ usertypes.NavigationRequest.Type.other,
+ }
+ navigation = usertypes.NavigationRequest(url=url,
+ navigation_type=type_map[typ],
+ is_main_frame=is_main_frame)
+ self.navigation_request.emit(navigation)
+ return navigation.accepted
@pyqtSlot('QUrl')
def _inject_userjs(self, url):
diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py
index ee01c40db..9b120e514 100644
--- a/qutebrowser/browser/webkit/webkitsettings.py
+++ b/qutebrowser/browser/webkit/webkitsettings.py
@@ -17,9 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# We get various "abstract but not overridden" warnings
-# pylint: disable=abstract-method
-
"""Bridge from QWebSettings to our own settings.
Module attributes:
@@ -37,85 +34,130 @@ from qutebrowser.utils import standarddir, urlutils
from qutebrowser.browser import shared
-class Base(websettings.Base):
-
- """Base settings class with appropriate _get_global_settings."""
-
- def _get_global_settings(self):
- return [QWebSettings.globalSettings()]
-
-
-class Attribute(Base, websettings.Attribute):
-
- """A setting set via QWebSettings::setAttribute."""
-
- ENUM_BASE = QWebSettings
-
-
-class Setter(Base, websettings.Setter):
-
- """A setting set via a QWebSettings setter method."""
-
- pass
-
-
-class StaticSetter(Base, websettings.StaticSetter):
-
- """A setting set via a static QWebSettings setter method."""
-
- pass
-
-
-class FontFamilySetter(Base, websettings.FontFamilySetter):
+# The global WebKitSettings object
+global_settings = None
+
+
+class WebKitSettings(websettings.AbstractSettings):
+
+ """A wrapper for the config for QWebSettings."""
+
+ _ATTRIBUTES = {
+ 'content.images':
+ [QWebSettings.AutoLoadImages],
+ 'content.javascript.enabled':
+ [QWebSettings.JavascriptEnabled],
+ 'content.javascript.can_open_tabs_automatically':
+ [QWebSettings.JavascriptCanOpenWindows],
+ 'content.javascript.can_close_tabs':
+ [QWebSettings.JavascriptCanCloseWindows],
+ 'content.javascript.can_access_clipboard':
+ [QWebSettings.JavascriptCanAccessClipboard],
+ 'content.plugins':
+ [QWebSettings.PluginsEnabled],
+ 'content.webgl':
+ [QWebSettings.WebGLEnabled],
+ 'content.hyperlink_auditing':
+ [QWebSettings.HyperlinkAuditingEnabled],
+ 'content.local_content_can_access_remote_urls':
+ [QWebSettings.LocalContentCanAccessRemoteUrls],
+ 'content.local_content_can_access_file_urls':
+ [QWebSettings.LocalContentCanAccessFileUrls],
+ 'content.dns_prefetch':
+ [QWebSettings.DnsPrefetchEnabled],
+ 'content.frame_flattening':
+ [QWebSettings.FrameFlatteningEnabled],
+ 'content.cache.appcache':
+ [QWebSettings.OfflineWebApplicationCacheEnabled],
+ 'content.local_storage':
+ [QWebSettings.LocalStorageEnabled,
+ QWebSettings.OfflineStorageDatabaseEnabled],
+ 'content.developer_extras':
+ [QWebSettings.DeveloperExtrasEnabled],
+ 'content.print_element_backgrounds':
+ [QWebSettings.PrintElementBackgrounds],
+ 'content.xss_auditing':
+ [QWebSettings.XSSAuditingEnabled],
+
+ 'input.spatial_navigation':
+ [QWebSettings.SpatialNavigationEnabled],
+ 'input.links_included_in_focus_chain':
+ [QWebSettings.LinksIncludedInFocusChain],
+
+ 'zoom.text_only':
+ [QWebSettings.ZoomTextOnly],
+ 'scrolling.smooth':
+ [QWebSettings.ScrollAnimatorEnabled],
+ }
- """A setter for a font family.
+ _FONT_SIZES = {
+ 'fonts.web.size.minimum':
+ QWebSettings.MinimumFontSize,
+ 'fonts.web.size.minimum_logical':
+ QWebSettings.MinimumLogicalFontSize,
+ 'fonts.web.size.default':
+ QWebSettings.DefaultFontSize,
+ 'fonts.web.size.default_fixed':
+ QWebSettings.DefaultFixedFontSize,
+ }
- Gets the default value from QFont.
- """
+ _FONT_FAMILIES = {
+ 'fonts.web.family.standard': QWebSettings.StandardFont,
+ 'fonts.web.family.fixed': QWebSettings.FixedFont,
+ 'fonts.web.family.serif': QWebSettings.SerifFont,
+ 'fonts.web.family.sans_serif': QWebSettings.SansSerifFont,
+ 'fonts.web.family.cursive': QWebSettings.CursiveFont,
+ 'fonts.web.family.fantasy': QWebSettings.FantasyFont,
+ }
- def __init__(self, font):
- # Mapping from QWebSettings::QWebSettings() in
- # qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp
- font_to_qfont = {
- QWebSettings.StandardFont: QFont.Serif,
- QWebSettings.FixedFont: QFont.Monospace,
- QWebSettings.SerifFont: QFont.Serif,
- QWebSettings.SansSerifFont: QFont.SansSerif,
- QWebSettings.CursiveFont: QFont.Cursive,
- QWebSettings.FantasyFont: QFont.Fantasy,
- }
- super().__init__(setter=QWebSettings.setFontFamily, font=font,
- qfont=font_to_qfont[font])
+ # Mapping from QWebSettings::QWebSettings() in
+ # qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp
+ _FONT_TO_QFONT = {
+ QWebSettings.StandardFont: QFont.Serif,
+ QWebSettings.FixedFont: QFont.Monospace,
+ QWebSettings.SerifFont: QFont.Serif,
+ QWebSettings.SansSerifFont: QFont.SansSerif,
+ QWebSettings.CursiveFont: QFont.Cursive,
+ QWebSettings.FantasyFont: QFont.Fantasy,
+ }
-class CookiePolicy(Base):
+def _set_user_stylesheet(settings):
+ """Set the generated user-stylesheet."""
+ stylesheet = shared.get_user_stylesheet().encode('utf-8')
+ url = urlutils.data_url('text/css;charset=utf-8', stylesheet)
+ settings.setUserStyleSheetUrl(url)
- """The ThirdPartyCookiePolicy setting is different from other settings."""
- MAPPING = {
+def _set_cookie_accept_policy(settings):
+ """Update the content.cookies.accept setting."""
+ mapping = {
'all': QWebSettings.AlwaysAllowThirdPartyCookies,
'no-3rdparty': QWebSettings.AlwaysBlockThirdPartyCookies,
'never': QWebSettings.AlwaysBlockThirdPartyCookies,
'no-unknown-3rdparty': QWebSettings.AllowThirdPartyWithExistingCookies,
}
-
- def _set(self, value, settings=None):
- for obj in self._get_settings(settings):
- obj.setThirdPartyCookiePolicy(self.MAPPING[value])
+ value = config.val.content.cookies.accept
+ settings.setThirdPartyCookiePolicy(mapping[value])
-def _set_user_stylesheet():
- """Set the generated user-stylesheet."""
- stylesheet = shared.get_user_stylesheet().encode('utf-8')
- url = urlutils.data_url('text/css;charset=utf-8', stylesheet)
- QWebSettings.globalSettings().setUserStyleSheetUrl(url)
+def _set_cache_maximum_pages(settings):
+ """Update the content.cache.maximum_pages setting."""
+ value = config.val.content.cache.maximum_pages
+ settings.setMaximumPagesInCache(value)
def _update_settings(option):
"""Update global settings when qwebsettings changed."""
+ global_settings.update_setting(option)
+
+ settings = QWebSettings.globalSettings()
if option in ['scrollbar.hide', 'content.user_stylesheets']:
- _set_user_stylesheet()
- websettings.update_mappings(MAPPINGS, option)
+ _set_user_stylesheet(settings)
+ elif option == 'content.cookies.accept':
+ _set_cookie_accept_policy(settings)
+ elif option == 'content.cache.maximum_pages':
+ _set_cache_maximum_pages(settings)
def init(_args):
@@ -131,92 +173,20 @@ def init(_args):
QWebSettings.setOfflineStoragePath(
os.path.join(data_path, 'offline-storage'))
- websettings.init_mappings(MAPPINGS)
- _set_user_stylesheet()
+ settings = QWebSettings.globalSettings()
+ _set_user_stylesheet(settings)
+ _set_cookie_accept_policy(settings)
+ _set_cache_maximum_pages(settings)
+
config.instance.changed.connect(_update_settings)
+ global global_settings
+ global_settings = WebKitSettings(QWebSettings.globalSettings())
+ global_settings.init_settings()
+
def shutdown():
"""Disable storage so removing tmpdir will work."""
QWebSettings.setIconDatabasePath('')
QWebSettings.setOfflineWebApplicationCachePath('')
QWebSettings.globalSettings().setLocalStoragePath('')
-
-
-MAPPINGS = {
- 'content.images':
- Attribute(QWebSettings.AutoLoadImages),
- 'content.javascript.enabled':
- Attribute(QWebSettings.JavascriptEnabled),
- 'content.javascript.can_open_tabs_automatically':
- Attribute(QWebSettings.JavascriptCanOpenWindows),
- 'content.javascript.can_close_tabs':
- Attribute(QWebSettings.JavascriptCanCloseWindows),
- 'content.javascript.can_access_clipboard':
- Attribute(QWebSettings.JavascriptCanAccessClipboard),
- 'content.plugins':
- Attribute(QWebSettings.PluginsEnabled),
- 'content.webgl':
- Attribute(QWebSettings.WebGLEnabled),
- 'content.hyperlink_auditing':
- Attribute(QWebSettings.HyperlinkAuditingEnabled),
- 'content.local_content_can_access_remote_urls':
- Attribute(QWebSettings.LocalContentCanAccessRemoteUrls),
- 'content.local_content_can_access_file_urls':
- Attribute(QWebSettings.LocalContentCanAccessFileUrls),
- 'content.cookies.accept':
- CookiePolicy(),
- 'content.dns_prefetch':
- Attribute(QWebSettings.DnsPrefetchEnabled),
- 'content.frame_flattening':
- Attribute(QWebSettings.FrameFlatteningEnabled),
- 'content.cache.appcache':
- Attribute(QWebSettings.OfflineWebApplicationCacheEnabled),
- 'content.local_storage':
- Attribute(QWebSettings.LocalStorageEnabled,
- QWebSettings.OfflineStorageDatabaseEnabled),
- 'content.cache.maximum_pages':
- StaticSetter(QWebSettings.setMaximumPagesInCache),
- 'content.developer_extras':
- Attribute(QWebSettings.DeveloperExtrasEnabled),
- 'content.print_element_backgrounds':
- Attribute(QWebSettings.PrintElementBackgrounds),
- 'content.xss_auditing':
- Attribute(QWebSettings.XSSAuditingEnabled),
- 'content.default_encoding':
- Setter(QWebSettings.setDefaultTextEncoding),
- # content.user_stylesheets is handled separately
-
- 'input.spatial_navigation':
- Attribute(QWebSettings.SpatialNavigationEnabled),
- 'input.links_included_in_focus_chain':
- Attribute(QWebSettings.LinksIncludedInFocusChain),
-
- 'fonts.web.family.standard':
- FontFamilySetter(QWebSettings.StandardFont),
- 'fonts.web.family.fixed':
- FontFamilySetter(QWebSettings.FixedFont),
- 'fonts.web.family.serif':
- FontFamilySetter(QWebSettings.SerifFont),
- 'fonts.web.family.sans_serif':
- FontFamilySetter(QWebSettings.SansSerifFont),
- 'fonts.web.family.cursive':
- FontFamilySetter(QWebSettings.CursiveFont),
- 'fonts.web.family.fantasy':
- FontFamilySetter(QWebSettings.FantasyFont),
- 'fonts.web.size.minimum':
- Setter(QWebSettings.setFontSize, args=[QWebSettings.MinimumFontSize]),
- 'fonts.web.size.minimum_logical':
- Setter(QWebSettings.setFontSize,
- args=[QWebSettings.MinimumLogicalFontSize]),
- 'fonts.web.size.default':
- Setter(QWebSettings.setFontSize, args=[QWebSettings.DefaultFontSize]),
- 'fonts.web.size.default_fixed':
- Setter(QWebSettings.setFontSize,
- args=[QWebSettings.DefaultFixedFontSize]),
-
- 'zoom.text_only':
- Attribute(QWebSettings.ZoomTextOnly),
- 'scrolling.smooth':
- Attribute(QWebSettings.ScrollAnimatorEnabled),
-}
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 9395630db..17757a761 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -30,13 +30,14 @@ import pygments.formatters
import sip
from PyQt5.QtCore import (pyqtSlot, Qt, QEvent, QUrl, QPoint, QTimer, QSizeF,
QSize)
-from PyQt5.QtGui import QKeyEvent
+from PyQt5.QtGui import QKeyEvent, QIcon
from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtPrintSupport import QPrinter
-from qutebrowser.browser import browsertab
-from qutebrowser.browser.webkit import webview, tabhistory, webkitelem
+from qutebrowser.browser import browsertab, shared
+from qutebrowser.browser.webkit import (webview, tabhistory, webkitelem,
+ webkitsettings)
from qutebrowser.utils import qtutils, objreg, usertypes, utils, log, debug
@@ -146,8 +147,17 @@ class WebKitSearch(browsertab.AbstractSearch):
def search(self, text, *, ignore_case='never', reverse=False,
result_cb=None):
- self.search_displayed = True
+ # Don't go to next entry on duplicate search
+ if self.text == text and self.search_displayed:
+ log.webview.debug("Ignoring duplicate search request"
+ " for {}".format(text))
+ return
+
+ # Clear old search results, this is done automatically on QtWebEngine.
+ self.clear()
+
self.text = text
+ self.search_displayed = True
self._flags = QWebPage.FindWrapsAroundDocument
if self._is_case_sensitive(ignore_case):
self._flags |= QWebPage.FindCaseSensitively
@@ -205,8 +215,8 @@ class WebKitCaret(browsertab.AbstractCaret):
self._widget.page().currentFrame().evaluateJavaScript(
utils.read_file('javascript/position_caret.js'))
- @pyqtSlot()
- def _on_mode_left(self):
+ @pyqtSlot(usertypes.KeyMode)
+ def _on_mode_left(self, _mode):
settings = self._widget.settings()
if settings.testAttribute(QWebSettings.CaretBrowsingEnabled):
if self.selection_enabled and self._widget.hasSelection():
@@ -517,7 +527,8 @@ class WebKitHistory(browsertab.AbstractHistory):
return self._history.itemAt(i)
def _go_to_item(self, item):
- return self._history.goToItem(item)
+ self._tab.predicted_navigation.emit(item.url())
+ self._history.goToItem(item)
def serialize(self):
return qtutils.serialize(self._history)
@@ -526,6 +537,9 @@ class WebKitHistory(browsertab.AbstractHistory):
return qtutils.deserialize(data, self._history)
def load_items(self, items):
+ if items:
+ self._tab.predicted_navigation.emit(items[-1].url)
+
stream, _data, user_data = tabhistory.serialize(items)
qtutils.deserialize_stream(stream, self._history)
for i, data in enumerate(user_data):
@@ -644,6 +658,8 @@ class WebKitTab(browsertab.AbstractTab):
self.printing = WebKitPrinting()
self.elements = WebKitElements(tab=self)
self.action = WebKitAction(tab=self)
+ # We're assigning settings in _set_widget
+ self.settings = webkitsettings.WebKitSettings(settings=None)
self._set_widget(widget)
self._connect_signals()
self.backend = usertypes.Backend.QtWebKit
@@ -655,8 +671,8 @@ class WebKitTab(browsertab.AbstractTab):
settings = widget.settings()
settings.setAttribute(QWebSettings.PrivateBrowsingEnabled, True)
- def openurl(self, url):
- self._openurl_prepare(url)
+ def openurl(self, url, *, predict=True):
+ self._openurl_prepare(url, predict=predict)
self._widget.openurl(url)
def url(self, requested=False):
@@ -730,6 +746,8 @@ class WebKitTab(browsertab.AbstractTab):
def _on_load_started(self):
super()._on_load_started()
self.networkaccessmanager().netrc_used = False
+ # Make sure the icon is cleared when navigating to a page without one.
+ self.icon_changed.emit(QIcon())
@pyqtSlot()
def _on_frame_load_finished(self):
@@ -761,6 +779,31 @@ class WebKitTab(browsertab.AbstractTab):
def _on_contents_size_changed(self, size):
self.contents_size_changed.emit(QSizeF(size))
+ @pyqtSlot(usertypes.NavigationRequest)
+ def _on_navigation_request(self, navigation):
+ super()._on_navigation_request(navigation)
+ if not navigation.accepted:
+ return
+
+ log.webview.debug("target {} override {}".format(
+ self.data.open_target, self.data.override_target))
+
+ if self.data.override_target is not None:
+ target = self.data.override_target
+ self.data.override_target = None
+ else:
+ target = self.data.open_target
+
+ if (navigation.navigation_type == navigation.Type.link_clicked and
+ target != usertypes.ClickTarget.normal):
+ tab = shared.get_tab(self.win_id, target)
+ tab.openurl(navigation.url)
+ self.data.open_target = usertypes.ClickTarget.normal
+ navigation.accepted = False
+
+ if navigation.is_main_frame:
+ self.settings.update_for_url(navigation.url)
+
def _connect_signals(self):
view = self._widget
page = view.page()
@@ -779,6 +822,7 @@ class WebKitTab(browsertab.AbstractTab):
page.frameCreated.connect(self._on_frame_created)
frame.contentsSizeChanged.connect(self._on_contents_size_changed)
frame.initialLayoutCompleted.connect(self._on_history_trigger)
+ page.navigation_request.connect(self._on_navigation_request)
def event_target(self):
return self._widget
diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py
index 679ec2d88..7b0a5caf5 100644
--- a/qutebrowser/browser/webkit/webpage.py
+++ b/qutebrowser/browser/webkit/webpage.py
@@ -22,6 +22,7 @@
import html
import functools
+import sip
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QUrl, QPoint
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
@@ -33,8 +34,7 @@ from qutebrowser.config import config
from qutebrowser.browser import pdfjs, shared
from qutebrowser.browser.webkit import http
from qutebrowser.browser.webkit.network import networkmanager
-from qutebrowser.utils import (message, usertypes, log, jinja, objreg, debug,
- urlutils)
+from qutebrowser.utils import message, usertypes, log, jinja, objreg
class BrowserPage(QWebPage):
@@ -54,10 +54,12 @@ class BrowserPage(QWebPage):
shutting_down: Emitted when the page is currently shutting down.
reloading: Emitted before a web page reloads.
arg: The URL which gets reloaded.
+ navigation_request: Emitted on acceptNavigationRequest.
"""
shutting_down = pyqtSignal()
reloading = pyqtSignal(QUrl)
+ navigation_request = pyqtSignal(usertypes.NavigationRequest)
def __init__(self, win_id, tab_id, tabdata, private, parent=None):
super().__init__(parent)
@@ -70,7 +72,6 @@ class BrowserPage(QWebPage):
}
self._ignore_load_started = False
self.error_occurred = False
- self.open_target = usertypes.ClickTarget.normal
self._networkmanager = networkmanager.NetworkManager(
win_id=win_id, tab_id=tab_id, private=private, parent=self)
self.setNetworkAccessManager(self._networkmanager)
@@ -302,6 +303,10 @@ class BrowserPage(QWebPage):
Args:
frame: The QWebFrame to inject the user scripts into.
"""
+ if sip.isdeleted(frame):
+ log.greasemonkey.debug("_inject_userjs called for deleted frame!")
+ return
+
url = frame.url()
if url.isEmpty():
url = frame.requestedUrl()
@@ -474,7 +479,7 @@ class BrowserPage(QWebPage):
source, line, msg)
def acceptNavigationRequest(self,
- _frame: QWebFrame,
+ frame: QWebFrame,
request: QNetworkRequest,
typ: QWebPage.NavigationType):
"""Override acceptNavigationRequest to handle clicked links.
@@ -486,36 +491,27 @@ class BrowserPage(QWebPage):
Checks if it should open it in a tab (middle-click or control) or not,
and then conditionally opens the URL here or in another tab/window.
"""
- url = request.url()
- log.webview.debug("navigation request: url {}, type {}, "
- "target {} override {}".format(
- url.toDisplayString(),
- debug.qenum_key(QWebPage, typ),
- self.open_target,
- self._tabdata.override_target))
-
- if self._tabdata.override_target is not None:
- target = self._tabdata.override_target
- self._tabdata.override_target = None
- else:
- target = self.open_target
-
- if typ == QWebPage.NavigationTypeReload:
- self.reloading.emit(url)
- return True
- elif typ != QWebPage.NavigationTypeLinkClicked:
- return True
-
- if not url.isValid():
- msg = urlutils.get_errstring(url, "Invalid link clicked")
- message.error(msg)
- self.open_target = usertypes.ClickTarget.normal
- return False
+ type_map = {
+ QWebPage.NavigationTypeLinkClicked:
+ usertypes.NavigationRequest.Type.link_clicked,
+ QWebPage.NavigationTypeFormSubmitted:
+ usertypes.NavigationRequest.Type.form_submitted,
+ QWebPage.NavigationTypeFormResubmitted:
+ usertypes.NavigationRequest.Type.form_resubmitted,
+ QWebPage.NavigationTypeBackOrForward:
+ usertypes.NavigationRequest.Type.back_forward,
+ QWebPage.NavigationTypeReload:
+ usertypes.NavigationRequest.Type.reloaded,
+ QWebPage.NavigationTypeOther:
+ usertypes.NavigationRequest.Type.other,
+ }
+ is_main_frame = frame is self.mainFrame()
+ navigation = usertypes.NavigationRequest(url=request.url(),
+ navigation_type=type_map[typ],
+ is_main_frame=is_main_frame)
- if target == usertypes.ClickTarget.normal:
- return True
+ if navigation.navigation_type == navigation.Type.reloaded:
+ self.reloading.emit(navigation.url)
- tab = shared.get_tab(self._win_id, target)
- tab.openurl(url)
- self.open_target = usertypes.ClickTarget.normal
- return False
+ self.navigation_request.emit(navigation)
+ return navigation.accepted
diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py
index 942e7265c..79da9778c 100644
--- a/qutebrowser/browser/webkit/webview.py
+++ b/qutebrowser/browser/webkit/webview.py
@@ -262,10 +262,10 @@ class WebView(QWebView):
target = usertypes.ClickTarget.tab_bg
else:
target = usertypes.ClickTarget.tab
- self.page().open_target = target
+ self._tabdata.open_target = target
log.mouse.debug("Ctrl/Middle click, setting target: {}".format(
target))
else:
- self.page().open_target = usertypes.ClickTarget.normal
+ self._tabdata.open_target = usertypes.ClickTarget.normal
log.mouse.debug("Normal click, setting normal target")
super().mousePressEvent(e)
diff --git a/qutebrowser/commands/__init__.py b/qutebrowser/commands/__init__.py
index 0bbc9852b..6ba8a9ae3 100644
--- a/qutebrowser/commands/__init__.py
+++ b/qutebrowser/commands/__init__.py
@@ -26,6 +26,7 @@ For command arguments, there are also some variables you can use:
- `{url}` expands to the URL of the current page
- `{url:pretty}` expands to the URL in decoded format
+- `{url:host}` expands to the host part of the URL
- `{clipboard}` expands to the clipboard contents
- `{primary}` expands to the primary selection contents
diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py
index 16c790eca..c3f5d87a1 100644
--- a/qutebrowser/commands/runners.py
+++ b/qutebrowser/commands/runners.py
@@ -63,9 +63,13 @@ def replace_variables(win_id, arglist):
QUrl.FullyEncoded | QUrl.RemovePassword),
'url:pretty': lambda: _current_url(tabbed_browser).toString(
QUrl.DecodeReserved | QUrl.RemovePassword),
+ 'url:host': lambda: _current_url(tabbed_browser).host(),
'clipboard': utils.get_clipboard,
'primary': lambda: utils.get_clipboard(selection=True),
}
+ for key in list(variables):
+ modified_key = '{' + key + '}'
+ variables[modified_key] = lambda x=modified_key: x
values = {}
args = []
tabbed_browser = objreg.get('tabbed-browser', scope='window',
diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py
index 09b80ed12..8506f3aa7 100644
--- a/qutebrowser/completion/completer.py
+++ b/qutebrowser/completion/completer.py
@@ -60,7 +60,7 @@ class Completer(QObject):
self._timer.setSingleShot(True)
self._timer.setInterval(0)
self._timer.timeout.connect(self._update_completion)
- self._last_cursor_pos = None
+ self._last_cursor_pos = -1
self._last_text = None
self._last_completion_func = None
self._cmd.update_completion.connect(self.schedule_completion_update)
diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py
index c433dbc12..b462442a0 100644
--- a/qutebrowser/completion/models/configmodel.py
+++ b/qutebrowser/completion/models/configmodel.py
@@ -22,13 +22,15 @@
from qutebrowser.config import configdata, configexc
from qutebrowser.completion.models import completionmodel, listcategory, util
from qutebrowser.commands import runners, cmdexc
+from qutebrowser.keyinput import keyutils
def option(*, info):
"""A CompletionModel filled with settings and their descriptions."""
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
options = ((opt.name, opt.description, info.config.get_str(opt.name))
- for opt in configdata.DATA.values())
+ for opt in configdata.DATA.values()
+ if not opt.no_autoconfig)
model.add_category(listcategory.ListCategory("Options", options))
return model
@@ -36,8 +38,10 @@ def option(*, info):
def customized_option(*, info):
"""A CompletionModel filled with set settings and their descriptions."""
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
- options = ((opt.name, opt.description, info.config.get_str(opt.name))
- for opt, _value in info.config)
+ options = ((values.opt.name, values.opt.description,
+ info.config.get_str(values.opt.name))
+ for values in info.config
+ if values)
model.add_category(listcategory.ListCategory("Customized options",
options))
return model
@@ -71,16 +75,16 @@ def value(optname, *_values, info):
return model
-def bind(key, *, info):
- """A CompletionModel filled with all bindable commands and descriptions.
-
- Args:
- key: the key being bound.
- """
- model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
+def _bind_current_default(key, info):
+ """Get current/default data for the given key."""
data = []
+ try:
+ seq = keyutils.KeySequence.parse(key)
+ except keyutils.KeyParseError as e:
+ data.append(('', str(e), key))
+ return data
- cmd_text = info.keyconf.get_command(key, 'normal')
+ cmd_text = info.keyconf.get_command(seq, 'normal')
if cmd_text:
parser = runners.CommandParser()
try:
@@ -90,12 +94,24 @@ def bind(key, *, info):
else:
data.append((cmd_text, '(Current) {}'.format(cmd.desc), key))
- cmd_text = info.keyconf.get_command(key, 'normal', default=True)
+ cmd_text = info.keyconf.get_command(seq, 'normal', default=True)
if cmd_text:
parser = runners.CommandParser()
cmd = parser.parse(cmd_text).cmd
data.append((cmd_text, '(Default) {}'.format(cmd.desc), key))
+ return data
+
+
+def bind(key, *, info):
+ """A CompletionModel filled with all bindable commands and descriptions.
+
+ Args:
+ key: the key being bound.
+ """
+ model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
+ data = _bind_current_default(key, info)
+
if data:
model.add_category(listcategory.ListCategory("Current/Default", data))
diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py
index a07f78143..60f801492 100644
--- a/qutebrowser/completion/models/histcategory.py
+++ b/qutebrowser/completion/models/histcategory.py
@@ -80,7 +80,7 @@ class HistoryCategory(QSqlQueryModel):
for i in range(len(words)))
# replace ' in timestamp-format to avoid breaking the query
- timestamp_format = config.val.completion.timestamp_format
+ timestamp_format = config.val.completion.timestamp_format or ''
timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')"
.format(timestamp_format.replace("'", "`")))
diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py
index 049d89295..8606bbf75 100644
--- a/qutebrowser/completion/models/miscmodels.py
+++ b/qutebrowser/completion/models/miscmodels.py
@@ -117,11 +117,11 @@ def _buffer(skip_win_id=None):
if tabbed_browser.shutting_down:
continue
tabs = []
- for idx in range(tabbed_browser.count()):
- tab = tabbed_browser.widget(idx)
+ for idx in range(tabbed_browser.widget.count()):
+ tab = tabbed_browser.widget.widget(idx)
tabs.append(("{}/{}".format(win_id, idx + 1),
tab.url().toDisplayString(),
- tabbed_browser.page_title(idx)))
+ tabbed_browser.widget.page_title(idx)))
cat = listcategory.ListCategory("{}".format(win_id), tabs,
delete_func=delete_buffer)
model.add_category(cat)
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
index f30acb8ca..7204fb8f3 100644
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -25,9 +25,10 @@ import functools
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject
-from qutebrowser.config import configdata, configexc
+from qutebrowser.config import configdata, configexc, configutils
from qutebrowser.utils import utils, log, jinja
from qutebrowser.misc import objects
+from qutebrowser.keyinput import keyutils
# An easy way to access the config from other code via config.val.foo
val = None
@@ -37,6 +38,9 @@ key_instance = None
# Keeping track of all change filters to validate them later.
change_filters = []
+# Sentinel
+UNSET = object()
+
class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
@@ -132,20 +136,18 @@ class KeyConfig:
def __init__(self, config):
self._config = config
- def _prepare(self, key, mode):
- """Make sure the given mode exists and normalize the key."""
+ def _validate(self, key, mode):
+ """Validate the given key and mode."""
+ # Catch old usage of this code
+ assert isinstance(key, keyutils.KeySequence), key
if mode not in configdata.DATA['bindings.default'].default:
raise configexc.KeybindingError("Invalid mode {}!".format(mode))
- if utils.is_special_key(key):
- # <Ctrl-t>, <ctrl-T>, and <ctrl-t> should be considered equivalent
- return utils.normalize_keystr(key)
- return key
def get_bindings_for(self, mode):
"""Get the combined bindings for the given mode."""
bindings = dict(val.bindings.default[mode])
for key, binding in val.bindings.commands[mode].items():
- if binding is None:
+ if not binding:
bindings.pop(key, None)
else:
bindings[key] = binding
@@ -155,20 +157,20 @@ class KeyConfig:
"""Get a dict of commands to a list of bindings for the mode."""
cmd_to_keys = {}
bindings = self.get_bindings_for(mode)
- for key, full_cmd in sorted(bindings.items()):
+ for seq, full_cmd in sorted(bindings.items()):
for cmd in full_cmd.split(';;'):
cmd = cmd.strip()
cmd_to_keys.setdefault(cmd, [])
- # put special bindings last
- if utils.is_special_key(key):
- cmd_to_keys[cmd].append(key)
+ # Put bindings involving modifiers last
+ if any(info.modifiers for info in seq):
+ cmd_to_keys[cmd].append(str(seq))
else:
- cmd_to_keys[cmd].insert(0, key)
+ cmd_to_keys[cmd].insert(0, str(seq))
return cmd_to_keys
def get_command(self, key, mode, default=False):
"""Get the command for a given key (or None)."""
- key = self._prepare(key, mode)
+ self._validate(key, mode)
if default:
bindings = dict(val.bindings.default[mode])
else:
@@ -182,23 +184,23 @@ class KeyConfig:
"Can't add binding '{}' with empty command in {} "
'mode'.format(key, mode))
- key = self._prepare(key, mode)
+ self._validate(key, mode)
log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format(
key, command, mode))
- bindings = self._config.get_obj('bindings.commands')
+ bindings = self._config.get_mutable_obj('bindings.commands')
if mode not in bindings:
bindings[mode] = {}
- bindings[mode][key] = command
+ bindings[mode][str(key)] = command
self._config.update_mutables(save_yaml=save_yaml)
def bind_default(self, key, *, mode='normal', save_yaml=False):
"""Restore a default keybinding."""
- key = self._prepare(key, mode)
+ self._validate(key, mode)
- bindings_commands = self._config.get_obj('bindings.commands')
+ bindings_commands = self._config.get_mutable_obj('bindings.commands')
try:
- del bindings_commands[mode][key]
+ del bindings_commands[mode][str(key)]
except KeyError:
raise configexc.KeybindingError(
"Can't find binding '{}' in {} mode".format(key, mode))
@@ -206,18 +208,18 @@ class KeyConfig:
def unbind(self, key, *, mode='normal', save_yaml=False):
"""Unbind the given key in the given mode."""
- key = self._prepare(key, mode)
+ self._validate(key, mode)
- bindings_commands = self._config.get_obj('bindings.commands')
+ bindings_commands = self._config.get_mutable_obj('bindings.commands')
if val.bindings.commands[mode].get(key, None) is not None:
# In custom bindings -> remove it
- del bindings_commands[mode][key]
+ del bindings_commands[mode][str(key)]
elif key in val.bindings.default[mode]:
# In default bindings -> shadow it with None
if mode not in bindings_commands:
bindings_commands[mode] = {}
- bindings_commands[mode][key] = None
+ bindings_commands[mode][str(key)] = None
else:
raise configexc.KeybindingError(
"Can't find binding '{}' in {} mode".format(key, mode))
@@ -229,8 +231,12 @@ class Config(QObject):
"""Main config object.
+ Class attributes:
+ MUTABLE_TYPES: Types returned from the config which could potentially
+ be mutated.
+
Attributes:
- _values: A dict mapping setting names to their values.
+ _values: A dict mapping setting names to configutils.Values objects.
_mutables: A dictionary of mutable objects to be checked for changes.
_yaml: A YamlConfig object or None.
@@ -238,19 +244,25 @@ class Config(QObject):
changed: Emitted with the option name when an option changed.
"""
+ MUTABLE_TYPES = (dict, list)
changed = pyqtSignal(str)
def __init__(self, yaml_config, parent=None):
super().__init__(parent)
self.changed.connect(_render_stylesheet.cache_clear)
- self._values = {}
self._mutables = {}
self._yaml = yaml_config
+ self._init_values()
+
+ def _init_values(self):
+ """Populate the self._values dict."""
+ self._values = {}
+ for name, opt in configdata.DATA.items():
+ self._values[name] = configutils.Values(opt)
def __iter__(self):
- """Iterate over Option, value tuples."""
- for name, value in sorted(self._values.items()):
- yield (self.get_opt(name), value)
+ """Iterate over configutils.Values items."""
+ yield from self._values.values()
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
@@ -260,24 +272,32 @@ class Config(QObject):
"""
self._yaml.init_save_manager(save_manager)
- def _set_value(self, opt, value):
+ def _set_value(self, opt, value, pattern=None):
"""Set the given option to the given value."""
if not isinstance(objects.backend, objects.NoBackend):
if objects.backend not in opt.backends:
raise configexc.BackendError(opt.name, objects.backend)
opt.typ.to_py(value) # for validation
- self._values[opt.name] = opt.typ.from_obj(value)
+
+ self._values[opt.name].add(opt.typ.from_obj(value), pattern)
self.changed.emit(opt.name)
log.config.debug("Config option changed: {} = {}".format(
opt.name, value))
+ def _check_yaml(self, opt, save_yaml):
+ """Make sure the given option may be set in autoconfig.yml."""
+ if save_yaml and opt.no_autoconfig:
+ raise configexc.NoAutoconfigError(opt.name)
+
def read_yaml(self):
"""Read the YAML settings from self._yaml."""
self._yaml.load()
- for name, value in self._yaml:
- self._set_value(self.get_opt(name), value)
+ for values in self._yaml:
+ for scoped in values:
+ self._set_value(values.opt, scoped.value,
+ pattern=scoped.pattern)
def get_opt(self, name):
"""Get a configdata.Option object for the given setting."""
@@ -290,77 +310,115 @@ class Config(QObject):
name, deleted=deleted, renamed=renamed)
raise exception from None
- def get(self, name):
+ def get(self, name, url=None):
"""Get the given setting converted for Python code."""
opt = self.get_opt(name)
- obj = self.get_obj(name, mutable=False)
+ obj = self.get_obj(name, url=url)
return opt.typ.to_py(obj)
- def get_obj(self, name, *, mutable=True):
+ def _maybe_copy(self, value):
+ """Copy the value if it could potentially be mutated."""
+ if isinstance(value, self.MUTABLE_TYPES):
+ # For mutable objects, create a copy so we don't accidentally
+ # mutate the config's internal value.
+ return copy.deepcopy(value)
+ else:
+ # Shouldn't be mutable (and thus hashable)
+ assert value.__hash__ is not None, value
+ return value
+
+ def get_obj(self, name, *, url=None):
"""Get the given setting as object (for YAML/config.py).
- If mutable=True is set, watch the returned object for mutations.
+ Note that the returned values are not watched for mutation.
+ If a URL is given, return the value which should be used for that URL.
"""
- opt = self.get_opt(name)
- obj = None
+ self.get_opt(name) # To make sure it exists
+ value = self._values[name].get_for_url(url)
+ return self._maybe_copy(value)
+
+ def get_obj_for_pattern(self, name, *, pattern):
+ """Get the given setting as object (for YAML/config.py).
+
+ This gets the overridden value for a given pattern, or
+ configutils.UNSET if no such override exists.
+ """
+ self.get_opt(name) # To make sure it exists
+ value = self._values[name].get_for_pattern(pattern, fallback=False)
+ return self._maybe_copy(value)
+
+ def get_mutable_obj(self, name, *, pattern=None):
+ """Get an object which can be mutated, e.g. in a config.py.
+
+ If a pattern is given, return the value for that pattern.
+ Note that it's impossible to get a mutable object for an URL as we
+ wouldn't know what pattern to apply.
+ """
+ self.get_opt(name) # To make sure it exists
+
# If we allow mutation, there is a chance that prior mutations already
# entered the mutable dictionary and thus further copies are unneeded
# until update_mutables() is called
- if name in self._mutables and mutable:
+ if name in self._mutables:
_copy, obj = self._mutables[name]
- # Otherwise, we return a copy of the value stored internally, so the
- # internal value can never be changed by mutating the object returned.
- else:
- obj = copy.deepcopy(self._values.get(name, opt.default))
- # Then we watch the returned object for changes.
- if isinstance(obj, (dict, list)):
- if mutable:
- self._mutables[name] = (copy.deepcopy(obj), obj)
- else:
- # Shouldn't be mutable (and thus hashable)
- assert obj.__hash__ is not None, obj
- return obj
+ return obj
+
+ value = self._values[name].get_for_pattern(pattern)
+ copy_value = self._maybe_copy(value)
+
+ # Watch the returned object for changes if it's mutable.
+ if isinstance(copy_value, self.MUTABLE_TYPES):
+ self._mutables[name] = (value, copy_value) # old, new
+
+ return copy_value
- def get_str(self, name):
- """Get the given setting as string."""
+ def get_str(self, name, *, pattern=None):
+ """Get the given setting as string.
+
+ If a pattern is given, get the setting for the given pattern or
+ configutils.UNSET.
+ """
opt = self.get_opt(name)
- value = self._values.get(name, opt.default)
+ values = self._values[name]
+ value = values.get_for_pattern(pattern)
return opt.typ.to_str(value)
- def set_obj(self, name, value, *, save_yaml=False):
+ def set_obj(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a YAML/config.py object.
If save_yaml=True is given, store the new value to YAML.
"""
- self._set_value(self.get_opt(name), value)
+ opt = self.get_opt(name)
+ self._check_yaml(opt, save_yaml)
+ self._set_value(opt, value, pattern=pattern)
if save_yaml:
- self._yaml[name] = value
+ self._yaml.set_obj(name, value, pattern=pattern)
- def set_str(self, name, value, *, save_yaml=False):
+ def set_str(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a string.
If save_yaml=True is given, store the new value to YAML.
"""
opt = self.get_opt(name)
+ self._check_yaml(opt, save_yaml)
converted = opt.typ.from_str(value)
log.config.debug("Setting {} (type {}) to {!r} (converted from {!r})"
.format(name, opt.typ.__class__.__name__, converted,
value))
- self._set_value(opt, converted)
+ self._set_value(opt, converted, pattern=pattern)
if save_yaml:
- self._yaml[name] = converted
+ self._yaml.set_obj(name, converted, pattern=pattern)
- def unset(self, name, *, save_yaml=False):
+ def unset(self, name, *, save_yaml=False, pattern=None):
"""Set the given setting back to its default."""
- self.get_opt(name)
- try:
- del self._values[name]
- except KeyError:
- return
- self.changed.emit(name)
+ opt = self.get_opt(name)
+ self._check_yaml(opt, save_yaml)
+ changed = self._values[name].remove(pattern)
+ if changed:
+ self.changed.emit(name)
if save_yaml:
- self._yaml.unset(name)
+ self._yaml.unset(name, pattern=pattern)
def clear(self, *, save_yaml=False):
"""Clear all settings in the config.
@@ -368,10 +426,10 @@ class Config(QObject):
If save_yaml=True is given, also remove all customization from the YAML
file.
"""
- old_values = self._values
- self._values = {}
- for name in old_values:
- self.changed.emit(name)
+ for name, values in self._values.items():
+ if values:
+ values.clear()
+ self.changed.emit(name)
if save_yaml:
self._yaml.clear()
@@ -397,13 +455,15 @@ class Config(QObject):
Return:
The changed config part as string.
"""
- lines = []
- for opt, value in self:
- str_value = opt.typ.to_str(value)
- lines.append('{} = {}'.format(opt.name, str_value))
- if not lines:
- lines = ['<Default configuration>']
- return '\n'.join(lines)
+ blocks = []
+ for values in sorted(self, key=lambda v: v.opt.name):
+ if values:
+ blocks.append(str(values))
+
+ if not blocks:
+ return '<Default configuration>'
+
+ return '\n'.join(blocks)
class ConfigContainer:
@@ -415,16 +475,21 @@ class ConfigContainer:
_prefix: The __getattr__ chain leading up to this object.
_configapi: If given, get values suitable for config.py and
add errors to the given ConfigAPI object.
+ _pattern: The URL pattern to be used.
"""
- def __init__(self, config, configapi=None, prefix=''):
+ def __init__(self, config, configapi=None, prefix='', pattern=None):
self._config = config
self._prefix = prefix
self._configapi = configapi
+ self._pattern = pattern
+ if configapi is None and pattern is not None:
+ raise TypeError("Can't use pattern without configapi!")
def __repr__(self):
return utils.get_repr(self, constructor=True, config=self._config,
- configapi=self._configapi, prefix=self._prefix)
+ configapi=self._configapi, prefix=self._prefix,
+ pattern=self._pattern)
@contextlib.contextmanager
def _handle_error(self, action, name):
@@ -452,7 +517,7 @@ class ConfigContainer:
if configdata.is_valid_prefix(name):
return ConfigContainer(config=self._config,
configapi=self._configapi,
- prefix=name)
+ prefix=name, pattern=self._pattern)
with self._handle_error('getting', name):
if self._configapi is None:
@@ -460,7 +525,8 @@ class ConfigContainer:
return self._config.get(name)
else:
# access from config.py
- return self._config.get_obj(name)
+ return self._config.get_mutable_obj(
+ name, pattern=self._pattern)
def __setattr__(self, attr, value):
"""Set the given option in the config."""
@@ -470,7 +536,7 @@ class ConfigContainer:
name = self._join(attr)
with self._handle_error('setting', name):
- self._config.set_obj(name, value)
+ self._config.set_obj(name, value, pattern=self._pattern)
def _join(self, attr):
"""Get the prefix joined with the given attribute."""
diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py
index 8bc2a9ed8..792eacaf0 100644
--- a/qutebrowser/config/configcommands.py
+++ b/qutebrowser/config/configcommands.py
@@ -26,9 +26,10 @@ from PyQt5.QtCore import QUrl
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.completion.models import configmodel
-from qutebrowser.utils import objreg, utils, message, standarddir
+from qutebrowser.utils import objreg, message, standarddir, urlmatch
from qutebrowser.config import configtypes, configexc, configfiles, configdata
from qutebrowser.misc import editor
+from qutebrowser.keyinput import keyutils
class ConfigCommands:
@@ -47,17 +48,41 @@ class ConfigCommands:
except configexc.Error as e:
raise cmdexc.CommandError(str(e))
- def _print_value(self, option):
+ def _parse_pattern(self, pattern):
+ """Parse a pattern string argument to a pattern."""
+ if pattern is None:
+ return None
+
+ try:
+ return urlmatch.UrlPattern(pattern)
+ except urlmatch.ParseError as e:
+ raise cmdexc.CommandError("Error while parsing {}: {}"
+ .format(pattern, str(e)))
+
+ def _parse_key(self, key):
+ """Parse a key argument."""
+ try:
+ return keyutils.KeySequence.parse(key)
+ except keyutils.KeyParseError as e:
+ raise cmdexc.CommandError(str(e))
+
+ def _print_value(self, option, pattern):
"""Print the value of the given option."""
with self._handle_config_error():
- value = self._config.get_str(option)
- message.info("{} = {}".format(option, value))
+ value = self._config.get_str(option, pattern=pattern)
+
+ text = "{} = {}".format(option, value)
+ if pattern is not None:
+ text += " for {}".format(pattern)
+ message.info(text)
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('value', completion=configmodel.value)
@cmdutils.argument('win_id', win_id=True)
- def set(self, win_id, option=None, value=None, temp=False, print_=False):
+ @cmdutils.argument('pattern', flag='u')
+ def set(self, win_id, option=None, value=None, temp=False, print_=False,
+ *, pattern=None):
"""Set an option.
If the option name ends with '?', the value of the option is shown
@@ -69,6 +94,7 @@ class ConfigCommands:
Args:
option: The name of the option.
value: The value to set.
+ pattern: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
@@ -82,8 +108,10 @@ class ConfigCommands:
raise cmdexc.CommandError("Toggling values was moved to the "
":config-cycle command")
+ pattern = self._parse_pattern(pattern)
+
if option.endswith('?') and option != '?':
- self._print_value(option[:-1])
+ self._print_value(option[:-1], pattern=pattern)
return
with self._handle_config_error():
@@ -91,10 +119,11 @@ class ConfigCommands:
raise cmdexc.CommandError("set: The following arguments "
"are required: value")
else:
- self._config.set_str(option, value, save_yaml=not temp)
+ self._config.set_str(option, value, pattern=pattern,
+ save_yaml=not temp)
if print_:
- self._print_value(option)
+ self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands', maxsplit=1,
no_cmd_split=True, no_replace_variables=True)
@@ -108,7 +137,8 @@ class ConfigCommands:
Using :bind without any arguments opens a page showing all keybindings.
Args:
- key: The keychain or special key (inside `<...>`) to bind.
+ key: The keychain to bind. Examples of valid keychains are `gC`,
+ `<Ctrl-X>` or `<Ctrl-C>a`.
command: The command to execute, with optional args.
mode: A comma-separated list of modes to bind the key in
(default: `normal`). See `:help bindings.commands` for the
@@ -121,58 +151,64 @@ class ConfigCommands:
tabbed_browser.openurl(QUrl('qute://bindings'), newtab=True)
return
+ seq = self._parse_key(key)
+
if command is None:
if default:
# :bind --default: Restore default
with self._handle_config_error():
- self._keyconfig.bind_default(key, mode=mode,
+ self._keyconfig.bind_default(seq, mode=mode,
save_yaml=True)
return
# No --default -> print binding
- if utils.is_special_key(key):
- # self._keyconfig.get_command does this, but we also need it
- # normalized for the output below
- key = utils.normalize_keystr(key)
with self._handle_config_error():
- cmd = self._keyconfig.get_command(key, mode)
+ cmd = self._keyconfig.get_command(seq, mode)
if cmd is None:
- message.info("{} is unbound in {} mode".format(key, mode))
+ message.info("{} is unbound in {} mode".format(seq, mode))
else:
message.info("{} is bound to '{}' in {} mode".format(
- key, cmd, mode))
+ seq, cmd, mode))
return
with self._handle_config_error():
- self._keyconfig.bind(key, command, mode=mode, save_yaml=True)
+ self._keyconfig.bind(seq, command, mode=mode, save_yaml=True)
@cmdutils.register(instance='config-commands')
def unbind(self, key, *, mode='normal'):
"""Unbind a keychain.
Args:
- key: The keychain or special key (inside <...>) to unbind.
+ key: The keychain to unbind. See the help for `:bind` for the
+ correct syntax for keychains.
mode: A mode to unbind the key in (default: `normal`).
See `:help bindings.commands` for the available modes.
"""
with self._handle_config_error():
- self._keyconfig.unbind(key, mode=mode, save_yaml=True)
+ self._keyconfig.unbind(self._parse_key(key), mode=mode,
+ save_yaml=True)
@cmdutils.register(instance='config-commands', star_args_optional=True)
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('values', completion=configmodel.value)
- def config_cycle(self, option, *values, temp=False, print_=False):
+ @cmdutils.argument('pattern', flag='u')
+ def config_cycle(self, option, *values, pattern=None, temp=False,
+ print_=False):
"""Cycle an option between multiple values.
Args:
option: The name of the option.
values: The values to cycle through.
+ pattern: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
+ pattern = self._parse_pattern(pattern)
+
with self._handle_config_error():
opt = self._config.get_opt(option)
- old_value = self._config.get_obj(option, mutable=False)
+ old_value = self._config.get_obj_for_pattern(option,
+ pattern=pattern)
if not values and isinstance(opt.typ, configtypes.Bool):
values = ['true', 'false']
@@ -194,10 +230,11 @@ class ConfigCommands:
value = values[0]
with self._handle_config_error():
- self._config.set_obj(option, value, save_yaml=not temp)
+ self._config.set_obj(option, value, pattern=pattern,
+ save_yaml=not temp)
if print_:
- self._print_value(option)
+ self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.customized_option)
@@ -291,13 +328,16 @@ class ConfigCommands:
"overwrite!".format(filename))
if defaults:
- options = [(opt, opt.default)
+ options = [(None, opt, opt.default)
for _name, opt in sorted(configdata.DATA.items())]
bindings = dict(configdata.DATA['bindings.default'].default)
commented = True
else:
- options = list(self._config)
- bindings = dict(self._config.get_obj('bindings.commands'))
+ options = []
+ for values in self._config:
+ for scoped in values:
+ options.append((scoped.pattern, values.opt, scoped.value))
+ bindings = dict(self._config.get_mutable_obj('bindings.commands'))
commented = False
writer = configfiles.ConfigPyWriter(options, bindings,
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index 3e0a6d8b1..c617fca14 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -48,7 +48,9 @@ class Option:
backends = attr.ib()
raw_backends = attr.ib()
description = attr.ib()
+ supports_pattern = attr.ib(default=False)
restart = attr.ib(default=False)
+ no_autoconfig = attr.ib(default=False)
@attr.s
@@ -197,7 +199,8 @@ def _read_yaml(yaml_data):
migrations = Migrations()
data = utils.yaml_load(yaml_data)
- keys = {'type', 'default', 'desc', 'backend', 'restart'}
+ keys = {'type', 'default', 'desc', 'backend', 'restart',
+ 'supports_pattern', 'no_autoconfig'}
for name, option in data.items():
if set(option.keys()) == {'renamed'}:
@@ -223,7 +226,10 @@ def _read_yaml(yaml_data):
backends=_parse_yaml_backends(name, backends),
raw_backends=backends if isinstance(backends, dict) else None,
description=option['desc'],
- restart=option.get('restart', False))
+ restart=option.get('restart', False),
+ supports_pattern=option.get('supports_pattern', False),
+ no_autoconfig=option.get('no_autoconfig', False),
+ )
# Make sure no key shadows another.
for key1 in parsed:
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 72450978b..a6a2d5317 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -240,6 +240,7 @@ content.cache.appcache:
default: true
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: >-
Enable support for the HTML 5 web application cache feature.
@@ -298,12 +299,14 @@ content.dns_prefetch:
default: true
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: Try to pre-fetch DNS entries to speed up browsing.
content.frame_flattening:
default: false
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: >-
Expand each subframe to its contents.
@@ -459,12 +462,14 @@ content.host_blocking.whitelist:
content.hyperlink_auditing:
default: false
type: Bool
+ supports_pattern: true
desc: Enable hyperlink auditing (`<a ping>`).
content.images:
default: true
type: Bool
desc: Load images automatically in web pages.
+ supports_pattern: true
content.javascript.alert:
default: true
@@ -474,6 +479,7 @@ content.javascript.alert:
content.javascript.can_access_clipboard:
default: false
type: Bool
+ supports_pattern: true
desc: >-
Allow JavaScript to read from or write to the clipboard.
@@ -484,16 +490,19 @@ content.javascript.can_close_tabs:
default: false
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: Allow JavaScript to close tabs.
content.javascript.can_open_tabs_automatically:
default: false
type: Bool
+ supports_pattern: true
desc: Allow JavaScript to open new tabs without user interaction.
content.javascript.enabled:
default: true
type: Bool
+ supports_pattern: true
desc: Enable JavaScript.
content.javascript.log:
@@ -536,16 +545,19 @@ content.javascript.prompt:
content.local_content_can_access_remote_urls:
default: false
type: Bool
+ supports_pattern: true
desc: Allow locally loaded documents to access remote URLs.
content.local_content_can_access_file_urls:
default: true
type: Bool
+ supports_pattern: true
desc: Allow locally loaded documents to access other local URLs.
content.local_storage:
default: true
type: Bool
+ supports_pattern: true
desc: Enable support for HTML 5 local storage and Web SQL.
content.media_capture:
@@ -583,6 +595,7 @@ content.pdfjs:
content.plugins:
default: false
type: Bool
+ supports_pattern: true
desc: Enable plugins in Web pages.
content.print_element_backgrounds:
@@ -591,6 +604,7 @@ content.print_element_backgrounds:
backend:
QtWebKit: true
QtWebEngine: Qt 5.8
+ supports_pattern: true
desc: >-
Draw the background color and images also when the page is printed.
@@ -631,11 +645,13 @@ content.user_stylesheets:
content.webgl:
default: true
type: Bool
+ supports_pattern: true
desc: Enable WebGL.
content.xss_auditing:
type: Bool
default: false
+ supports_pattern: true
desc: >-
Monitor load requests for cross-site scripting attempts.
@@ -965,6 +981,11 @@ input.insert_mode.auto_load:
desc: Automatically enter insert mode if an editable element is focused after
loading the page.
+input.insert_mode.auto_enter:
+ default: true
+ type: Bool
+ desc: Enter insert mode if an editable element is clicked.
+
input.insert_mode.auto_leave:
default: true
type: Bool
@@ -978,6 +999,7 @@ input.insert_mode.plugins:
input.links_included_in_focus_chain:
default: true
type: Bool
+ supports_pattern: true
desc: Include hyperlinks in the keyboard focus chain when tabbing.
input.partial_timeout:
@@ -1003,6 +1025,7 @@ input.rocker_gestures:
input.spatial_navigation:
default: false
type: Bool
+ supports_pattern: true
desc: >-
Enable spatial navigation.
@@ -1083,6 +1106,7 @@ scrolling.bar:
scrolling.smooth:
type: Bool
default: false
+ supports_pattern: true
desc: >-
Enable smooth scrolling for web pages.
@@ -1557,6 +1581,7 @@ zoom.text_only:
type: Bool
default: false
backend: QtWebKit
+ supports_pattern: true
desc: Apply the zoom factor on a frame only to the text or to all content.
## colors
@@ -2141,6 +2166,7 @@ bindings.key_mappings:
<Ctrl-Enter>: <Ctrl-Return>
type:
name: Dict
+ none_ok: true
keytype: Key
valtype: Key
desc: >-
@@ -2156,6 +2182,7 @@ bindings.key_mappings:
`bindings.commands`), the mapping is ignored.
bindings.default:
+ no_autoconfig: true
default:
normal:
<Escape>: clear-keychain ;; search ;; fullscreen --leave
@@ -2309,6 +2336,18 @@ bindings.default:
<Ctrl-p>: tab-pin
q: record-macro
"@": run-macro
+ tsh: config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload
+ tSh: config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload
+ tsH: config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload
+ tSH: config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload
+ tsu: config-cycle -p -t -u {url} content.javascript.enabled ;; reload
+ tSu: config-cycle -p -u {url} content.javascript.enabled ;; reload
+ tph: config-cycle -p -t -u *://{url:host}/* content.plugins ;; reload
+ tPh: config-cycle -p -u *://{url:host}/* content.plugins ;; reload
+ tpH: config-cycle -p -t -u *://*.{url:host}/* content.plugins ;; reload
+ tPH: config-cycle -p -u *://*.{url:host}/* content.plugins ;; reload
+ tpu: config-cycle -p -t -u {url} content.plugins ;; reload
+ tPu: config-cycle -p -u {url} content.plugins ;; reload
insert:
<Ctrl-E>: open-editor
<Shift-Ins>: insert-text {primary}
@@ -2353,8 +2392,6 @@ bindings.default:
<Escape>: leave-mode
prompt:
<Return>: prompt-accept
- y: prompt-accept yes
- n: prompt-accept no
<Ctrl-X>: prompt-open-download
<Shift-Tab>: prompt-item-focus prev
<Up>: prompt-item-focus prev
@@ -2377,6 +2414,13 @@ bindings.default:
<Ctrl-H>: rl-backward-delete-char
<Ctrl-Y>: rl-yank
<Escape>: leave-mode
+ yesno:
+ <Return>: prompt-accept
+ y: prompt-accept yes
+ n: prompt-accept no
+ <Alt-Y>: prompt-yank
+ <Alt-Shift-Y>: prompt-yank --sel
+ <Escape>: leave-mode
caret:
v: toggle-selection
<Space>: toggle-selection
@@ -2412,7 +2456,7 @@ bindings.default:
none_ok: true
keytype: String # section name
fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command',
- 'prompt', 'caret', 'register']
+ 'prompt', 'yesno', 'caret', 'register']
valtype:
name: Dict
none_ok: true
@@ -2436,14 +2480,14 @@ bindings.commands:
none_ok: true
keytype: String # section name
fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command',
- 'prompt', 'caret', 'register']
+ 'prompt', 'yesno', 'caret', 'register']
valtype:
name: Dict
none_ok: true
keytype: Key
valtype:
name: Command
- none_ok: true
+ none_ok: true # needed for :unbind
desc: >-
Keybindings mapping keys to commands in different modes.
@@ -2459,7 +2503,6 @@ bindings.commands:
If you want to map a key to another key, check the `bindings.key_mappings`
setting instead.
- For special keys (can't be part of a keychain), enclose them in `<`...`>`.
For modifiers, you can use either `-` or `+` as delimiters, and these
names:
@@ -2508,10 +2551,8 @@ bindings.commands:
* prompt: Entered when there's a prompt to display, like for download
locations or when invoked from JavaScript.
- +
- You can bind normal keys in this mode, but they will be only active when
- a yes/no-prompt is asked. For other prompt modes, you can only bind
- special keys.
+
+ * yesno: Entered when there's a yes/no prompt displayed.
* caret: Entered when pressing the `v` mode, used to select text using the
keyboard.
diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py
index 0a4986efa..4d1ab5d7f 100644
--- a/qutebrowser/config/configexc.py
+++ b/qutebrowser/config/configexc.py
@@ -31,6 +31,15 @@ class Error(Exception):
pass
+class NoAutoconfigError(Error):
+
+ """Raised when this option can't be set in autoconfig.yml."""
+
+ def __init__(self, name):
+ super().__init__("The {} setting can only be set in config.py!"
+ .format(name))
+
+
class BackendError(Error):
"""Raised when this setting is unavailable with the current backend."""
@@ -40,6 +49,15 @@ class BackendError(Error):
"backend!".format(name, backend.name))
+class NoPatternError(Error):
+
+ """Raised when the given setting does not support URL patterns."""
+
+ def __init__(self, name):
+ super().__init__("The {} setting does not support URL patterns!"
+ .format(name))
+
+
class ValidationError(Error):
"""Raised when a value for a config type was invalid.
@@ -92,6 +110,10 @@ class ConfigErrorDesc:
traceback = attr.ib(None)
def __str__(self):
+ if self.traceback:
+ return '{} - {}: {}'.format(self.text,
+ self.exception.__class__.__name__,
+ self.exception)
return '{}: {}'.format(self.text, self.exception)
def with_text(self, text):
diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py
index 692474075..fdb1583e0 100644
--- a/qutebrowser/config/configfiles.py
+++ b/qutebrowser/config/configfiles.py
@@ -32,8 +32,9 @@ import yaml
from PyQt5.QtCore import pyqtSignal, QObject, QSettings
import qutebrowser
-from qutebrowser.config import configexc, config, configdata
-from qutebrowser.utils import standarddir, utils, qtutils, log
+from qutebrowser.config import configexc, config, configdata, configutils
+from qutebrowser.keyinput import keyutils
+from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch
# The StateConfig instance
@@ -80,16 +81,19 @@ class YamlConfig(QObject):
VERSION: The current version number of the config file.
"""
- VERSION = 1
+ VERSION = 2
changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._filename = os.path.join(standarddir.config(auto=True),
'autoconfig.yml')
- self._values = {}
self._dirty = None
+ self._values = {}
+ for name, opt in configdata.DATA.items():
+ self._values[name] = configutils.Values(opt)
+
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
@@ -98,18 +102,9 @@ class YamlConfig(QObject):
"""
save_manager.add_saveable('yaml-config', self._save, self.changed)
- def __getitem__(self, name):
- return self._values[name]
-
- def __setitem__(self, name, value):
- self._values[name] = value
- self._mark_changed()
-
- def __contains__(self, name):
- return name in self._values
-
def __iter__(self):
- return iter(sorted(self._values.items()))
+ """Iterate over configutils.Values items."""
+ yield from self._values.values()
def _mark_changed(self):
"""Mark the YAML config as changed."""
@@ -121,7 +116,17 @@ class YamlConfig(QObject):
if not self._dirty:
return
- data = {'config_version': self.VERSION, 'global': self._values}
+ settings = {}
+ for name, values in sorted(self._values.items()):
+ if not values:
+ continue
+ settings[name] = {}
+ for scoped in values:
+ key = ('global' if scoped.pattern is None
+ else str(scoped.pattern))
+ settings[name][key] = scoped.value
+
+ data = {'config_version': self.VERSION, 'settings': settings}
with qtutils.savefile_open(self._filename) as f:
f.write(textwrap.dedent("""
# DO NOT edit this file by hand, qutebrowser will overwrite it.
@@ -130,6 +135,29 @@ class YamlConfig(QObject):
""".lstrip('\n')))
utils.yaml_dump(data, f)
+ def _pop_object(self, yaml_data, key, typ):
+ """Get a global object from the given data."""
+ if not isinstance(yaml_data, dict):
+ desc = configexc.ConfigErrorDesc("While loading data",
+ "Toplevel object is not a dict")
+ raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+
+ if key not in yaml_data:
+ desc = configexc.ConfigErrorDesc(
+ "While loading data",
+ "Toplevel object does not contain '{}' key".format(key))
+ raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+
+ data = yaml_data.pop(key)
+
+ if not isinstance(data, typ):
+ desc = configexc.ConfigErrorDesc(
+ "While loading data",
+ "'{}' object is not a {}".format(key, typ.__name__))
+ raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+
+ return data
+
def load(self):
"""Load configuration from the configured YAML file."""
try:
@@ -144,76 +172,132 @@ class YamlConfig(QObject):
desc = configexc.ConfigErrorDesc("While parsing", e)
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
- try:
- global_obj = yaml_data['global']
- except KeyError:
- desc = configexc.ConfigErrorDesc(
- "While loading data",
- "Toplevel object does not contain 'global' key")
- raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
- except TypeError:
- desc = configexc.ConfigErrorDesc("While loading data",
- "Toplevel object is not a dict")
- raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
-
- if not isinstance(global_obj, dict):
+ config_version = self._pop_object(yaml_data, 'config_version', int)
+ if config_version == 1:
+ settings = self._load_legacy_settings_object(yaml_data)
+ self._mark_changed()
+ elif config_version > self.VERSION:
desc = configexc.ConfigErrorDesc(
- "While loading data",
- "'global' object is not a dict")
+ "While reading",
+ "Can't read config from incompatible newer version")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+ else:
+ settings = self._load_settings_object(yaml_data)
+ self._dirty = False
+
+ settings = self._handle_migrations(settings)
+ self._validate(settings)
+ self._build_values(settings)
+
+ def _load_settings_object(self, yaml_data):
+ """Load the settings from the settings: key."""
+ return self._pop_object(yaml_data, 'settings', dict)
+
+ def _load_legacy_settings_object(self, yaml_data):
+ data = self._pop_object(yaml_data, 'global', dict)
+ settings = {}
+ for name, value in data.items():
+ settings[name] = {'global': value}
+ return settings
+
+ def _build_values(self, settings):
+ """Build up self._values from the values in the given dict."""
+ errors = []
+ for name, yaml_values in settings.items():
+ if not isinstance(yaml_values, dict):
+ errors.append(configexc.ConfigErrorDesc(
+ "While parsing {!r}".format(name), "value is not a dict"))
+ continue
- self._values = global_obj
- self._dirty = False
-
- self._handle_migrations()
- self._validate()
+ values = configutils.Values(configdata.DATA[name])
+ if 'global' in yaml_values:
+ values.add(yaml_values.pop('global'))
+
+ for pattern, value in yaml_values.items():
+ if not isinstance(pattern, str):
+ errors.append(configexc.ConfigErrorDesc(
+ "While parsing {!r}".format(name),
+ "pattern is not of type string"))
+ continue
+ try:
+ urlpattern = urlmatch.UrlPattern(pattern)
+ except urlmatch.ParseError as e:
+ errors.append(configexc.ConfigErrorDesc(
+ "While parsing pattern {!r} for {!r}"
+ .format(pattern, name), e))
+ continue
+ values.add(value, urlpattern)
+
+ self._values[name] = values
+
+ if errors:
+ raise configexc.ConfigFileErrors('autoconfig.yml', errors)
- def _handle_migrations(self):
+ def _handle_migrations(self, settings):
"""Migrate older configs to the newest format."""
# Simple renamed/deleted options
- for name in list(self._values):
+ for name in list(settings):
if name in configdata.MIGRATIONS.renamed:
new_name = configdata.MIGRATIONS.renamed[name]
log.config.debug("Renaming {} to {}".format(name, new_name))
- self._values[new_name] = self._values[name]
- del self._values[name]
+ settings[new_name] = settings[name]
+ del settings[name]
self._mark_changed()
elif name in configdata.MIGRATIONS.deleted:
log.config.debug("Removing {}".format(name))
- del self._values[name]
+ del settings[name]
self._mark_changed()
# tabs.persist_mode_on_change got merged into tabs.mode_on_change
old = 'tabs.persist_mode_on_change'
new = 'tabs.mode_on_change'
- if old in self._values:
- if self._values[old]:
- self._values[new] = 'persist'
- else:
- self._values[new] = 'normal'
- del self._values[old]
+ if old in settings:
+ settings[new] = {}
+ for scope, val in settings[old].items():
+ if val:
+ settings[new][scope] = 'persist'
+ else:
+ settings[new][scope] = 'normal'
+
+ del settings[old]
+ self._mark_changed()
+
+ # bindings.default can't be set in autoconfig.yml anymore, so ignore
+ # old values.
+ if 'bindings.default' in settings:
+ del settings['bindings.default']
self._mark_changed()
- def _validate(self):
+ return settings
+
+ def _validate(self, settings):
"""Make sure all settings exist."""
- unknown = set(self._values) - set(configdata.DATA)
+ unknown = []
+ for name in settings:
+ if name not in configdata.DATA:
+ unknown.append(name)
+
if unknown:
errors = [configexc.ConfigErrorDesc("While loading options",
"Unknown option {}".format(e))
for e in sorted(unknown)]
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
- def unset(self, name):
- """Remove the given option name if it's configured."""
- try:
- del self._values[name]
- except KeyError:
- return
+ def set_obj(self, name, value, *, pattern=None):
+ """Set the given setting to the given value."""
+ self._values[name].add(value, pattern)
self._mark_changed()
+ def unset(self, name, *, pattern=None):
+ """Remove the given option name if it's configured."""
+ changed = self._values[name].remove(pattern)
+ if changed:
+ self._mark_changed()
+
def clear(self):
"""Clear all values from the YAML file."""
- self._values = []
+ for values in self._values.values():
+ values.clear()
self._mark_changed()
@@ -242,6 +326,7 @@ class ConfigAPI:
@contextlib.contextmanager
def _handle_error(self, action, name):
+ """Catch config-related exceptions and save them in self.errors."""
try:
yield
except configexc.ConfigFileErrors as e:
@@ -251,30 +336,45 @@ class ConfigAPI:
except configexc.Error as e:
text = "While {} '{}'".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
+ except urlmatch.ParseError as e:
+ text = "While {} '{}' and parsing pattern".format(action, name)
+ self.errors.append(configexc.ConfigErrorDesc(text, e))
+ except keyutils.KeyParseError as e:
+ text = "While {} '{}' and parsing key".format(action, name)
+ self.errors.append(configexc.ConfigErrorDesc(text, e))
def finalize(self):
"""Do work which needs to be done after reading config.py."""
self._config.update_mutables()
def load_autoconfig(self):
+ """Load the autoconfig.yml file which is used for :set/:bind/etc."""
with self._handle_error('reading', 'autoconfig.yml'):
read_autoconfig()
- def get(self, name):
+ def get(self, name, pattern=None):
+ """Get a setting value from the config, optionally with a pattern."""
with self._handle_error('getting', name):
- return self._config.get_obj(name)
+ urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
+ return self._config.get_mutable_obj(name, pattern=urlpattern)
- def set(self, name, value):
+ def set(self, name, value, pattern=None):
+ """Set a setting value in the config, optionally with a pattern."""
with self._handle_error('setting', name):
- self._config.set_obj(name, value)
+ urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
+ self._config.set_obj(name, value, pattern=urlpattern)
def bind(self, key, command, mode='normal'):
+ """Bind a key to a command, with an optional key mode."""
with self._handle_error('binding', key):
- self._keyconfig.bind(key, command, mode=mode)
+ seq = keyutils.KeySequence.parse(key)
+ self._keyconfig.bind(seq, command, mode=mode)
def unbind(self, key, mode='normal'):
+ """Unbind a key from a command, with an optional key mode."""
with self._handle_error('unbinding', key):
- self._keyconfig.unbind(key, mode=mode)
+ seq = keyutils.KeySequence.parse(key)
+ self._keyconfig.unbind(seq, mode=mode)
def source(self, filename):
"""Read the given config file from disk."""
@@ -286,6 +386,16 @@ class ConfigAPI:
except configexc.ConfigFileErrors as e:
self.errors += e.errors
+ @contextlib.contextmanager
+ def pattern(self, pattern):
+ """Get a ConfigContainer for the given pattern."""
+ # We need to propagate the exception so we don't need to return
+ # something.
+ urlpattern = urlmatch.UrlPattern(pattern)
+ container = config.ConfigContainer(config=self._config, configapi=self,
+ pattern=urlpattern)
+ yield container
+
class ConfigPyWriter:
@@ -344,7 +454,7 @@ class ConfigPyWriter:
def _gen_options(self):
"""Generate the options part of the config."""
- for opt, value in self._options:
+ for pattern, opt, value in self._options:
if opt.name in ['bindings.commands', 'bindings.default']:
continue
@@ -363,7 +473,11 @@ class ConfigPyWriter:
except KeyError:
yield self._line("# - {}".format(val))
- yield self._line('c.{} = {!r}'.format(opt.name, value))
+ if pattern is None:
+ yield self._line('c.{} = {!r}'.format(opt.name, value))
+ else:
+ yield self._line('config.set({!r}, {!r}, {!r})'.format(
+ opt.name, value, str(pattern)))
yield ''
def _gen_bindings(self):
@@ -419,7 +533,7 @@ def read_config_py(filename, raising=False):
desc = configexc.ConfigErrorDesc("Error while compiling", e)
raise configexc.ConfigFileErrors(basename, [desc])
except SyntaxError as e:
- desc = configexc.ConfigErrorDesc("Syntax Error", e,
+ desc = configexc.ConfigErrorDesc("Unhandled exception", e,
traceback=traceback.format_exc())
raise configexc.ConfigFileErrors(basename, [desc])
diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py
index 26998d510..20e240690 100644
--- a/qutebrowser/config/configtypes.py
+++ b/qutebrowser/config/configtypes.py
@@ -62,6 +62,7 @@ from PyQt5.QtWidgets import QTabWidget, QTabBar
from qutebrowser.commands import cmdutils
from qutebrowser.config import configexc
from qutebrowser.utils import standarddir, utils, qtutils, urlutils
+from qutebrowser.keyinput import keyutils
SYSTEM_PROXY = object() # Return value for Proxy type
@@ -450,7 +451,7 @@ class List(BaseType):
def from_obj(self, value):
if value is None:
return []
- return value
+ return [self.valtype.from_obj(v) for v in value]
def to_py(self, value):
self._basic_py_validation(value, list)
@@ -505,6 +506,16 @@ class ListOrValue(BaseType):
self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs)
self.valtype = valtype
+ def _val_and_type(self, value):
+ """Get the value and type to use for to_str/to_doc/from_str."""
+ if isinstance(value, list):
+ if len(value) == 1:
+ return value[0], self.valtype
+ else:
+ return value, self.listtype
+ else:
+ return value, self.valtype
+
def get_name(self):
return self.listtype.get_name() + ', or ' + self.valtype.get_name()
@@ -532,25 +543,15 @@ class ListOrValue(BaseType):
if value is None:
return ''
- if isinstance(value, list):
- if len(value) == 1:
- return self.valtype.to_str(value[0])
- else:
- return self.listtype.to_str(value)
- else:
- return self.valtype.to_str(value)
+ val, typ = self._val_and_type(value)
+ return typ.to_str(val)
def to_doc(self, value, indent=0):
if value is None:
return 'empty'
- if isinstance(value, list):
- if len(value) == 1:
- return self.valtype.to_doc(value[0], indent)
- else:
- return self.listtype.to_doc(value, indent)
- else:
- return self.valtype.to_doc(value, indent)
+ val, typ = self._val_and_type(value)
+ return typ.to_doc(val)
class FlagList(List):
@@ -1198,7 +1199,9 @@ class Dict(BaseType):
def from_obj(self, value):
if value is None:
return {}
- return value
+
+ return {self.keytype.from_obj(key): self.valtype.from_obj(val)
+ for key, val in value.items()}
def _fill_fixed_keys(self, value):
"""Fill missing fixed keys with a None-value."""
@@ -1647,10 +1650,16 @@ class Key(BaseType):
"""A name of a key."""
+ def from_obj(self, value):
+ """Make sure key sequences are always normalized."""
+ return str(keyutils.KeySequence.parse(value))
+
def to_py(self, value):
self._basic_py_validation(value, str)
if not value:
return None
- if utils.is_special_key(value):
- value = '<{}>'.format(utils.normalize_keystr(value[1:-1]))
- return value
+
+ try:
+ return keyutils.KeySequence.parse(value)
+ except keyutils.KeyParseError as e:
+ raise configexc.ValidationError(value, str(e))
diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py
new file mode 100644
index 000000000..96fc0f02d
--- /dev/null
+++ b/qutebrowser/config/configutils.py
@@ -0,0 +1,186 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+
+"""Utilities and data structures used by various config code."""
+
+
+import attr
+
+from qutebrowser.utils import utils
+from qutebrowser.config import configexc
+
+
+class _UnsetObject:
+
+ """Sentinel object."""
+
+ __slots__ = ()
+
+ def __repr__(self):
+ return '<UNSET>'
+
+
+UNSET = _UnsetObject()
+
+
+@attr.s
+class ScopedValue:
+
+ """A configuration value which is valid for a UrlPattern.
+
+ Attributes:
+ value: The value itself.
+ pattern: The UrlPattern for the value, or None for global values.
+ """
+
+ value = attr.ib()
+ pattern = attr.ib()
+
+
+class Values:
+
+ """A collection of values for a single setting.
+
+ Currently, this is a list and iterates through all possible ScopedValues to
+ find matching ones.
+
+ In the future, it should be possible to optimize this by doing
+ pre-selection based on hosts, by making this a dict mapping the
+ non-wildcard part of the host to a list of matching ScopedValues.
+
+ That way, when searching for a setting for sub.example.com, we only have to
+ check 'sub.example.com', 'example.com', '.com' and '' instead of checking
+ all ScopedValues for the given setting.
+
+ Attributes:
+ opt: The Option being customized.
+ """
+
+ def __init__(self, opt, values=None):
+ self.opt = opt
+ self._values = values or []
+
+ def __repr__(self):
+ return utils.get_repr(self, opt=self.opt, values=self._values,
+ constructor=True)
+
+ def __str__(self):
+ """Get the values as human-readable string."""
+ if not self:
+ return '{}: <unchanged>'.format(self.opt.name)
+
+ lines = []
+ for scoped in self._values:
+ str_value = self.opt.typ.to_str(scoped.value)
+ if scoped.pattern is None:
+ lines.append('{} = {}'.format(self.opt.name, str_value))
+ else:
+ lines.append('{}: {} = {}'.format(
+ scoped.pattern, self.opt.name, str_value))
+ return '\n'.join(lines)
+
+ def __iter__(self):
+ """Yield ScopedValue elements.
+
+ This yields in "normal" order, i.e. global and then first-set settings
+ first.
+ """
+ yield from self._values
+
+ def __bool__(self):
+ """Check whether this value is customized."""
+ return bool(self._values)
+
+ def _check_pattern_support(self, arg):
+ """Make sure patterns are supported if one was given."""
+ if arg is not None and not self.opt.supports_pattern:
+ raise configexc.NoPatternError(self.opt.name)
+
+ def add(self, value, pattern=None):
+ """Add a value with the given pattern to the list of values."""
+ self._check_pattern_support(pattern)
+ self.remove(pattern)
+ scoped = ScopedValue(value, pattern)
+ self._values.append(scoped)
+
+ def remove(self, pattern=None):
+ """Remove the value with the given pattern.
+
+ If a matching pattern was removed, True is returned.
+ If no matching pattern was found, False is returned.
+ """
+ self._check_pattern_support(pattern)
+ old_len = len(self._values)
+ self._values = [v for v in self._values if v.pattern != pattern]
+ return old_len != len(self._values)
+
+ def clear(self):
+ """Clear all customization for this value."""
+ self._values = []
+
+ def _get_fallback(self, fallback):
+ """Get the fallback global/default value."""
+ for scoped in self._values:
+ if scoped.pattern is None:
+ return scoped.value
+
+ if fallback:
+ return self.opt.default
+ else:
+ return UNSET
+
+ def get_for_url(self, url=None, *, fallback=True):
+ """Get a config value, falling back when needed.
+
+ This first tries to find a value matching the URL (if given).
+ If there's no match:
+ With fallback=True, the global/default setting is returned.
+ With fallback=False, UNSET is returned.
+ """
+ self._check_pattern_support(url)
+ if url is not None:
+ for scoped in reversed(self._values):
+ if scoped.pattern is not None and scoped.pattern.matches(url):
+ return scoped.value
+
+ if not fallback:
+ return UNSET
+
+ return self._get_fallback(fallback)
+
+ def get_for_pattern(self, pattern, *, fallback=True):
+ """Get a value only if it's been overridden for the given pattern.
+
+ This is useful when showing values to the user.
+
+ If there's no match:
+ With fallback=True, the global/default setting is returned.
+ With fallback=False, UNSET is returned.
+ """
+ self._check_pattern_support(pattern)
+ if pattern is not None:
+ for scoped in reversed(self._values):
+ if scoped.pattern == pattern:
+ return scoped.value
+
+ if not fallback:
+ return UNSET
+
+ return self._get_fallback(fallback)
diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py
index fa8abb76f..cfb53e658 100644
--- a/qutebrowser/config/websettings.py
+++ b/qutebrowser/config/websettings.py
@@ -17,195 +17,151 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# We get various "abstract but not overridden" warnings
-# pylint: disable=abstract-method
-
"""Bridge from QWeb(Engine)Settings to our own settings."""
from PyQt5.QtGui import QFont
-from qutebrowser.config import config
-from qutebrowser.utils import log, utils, debug, usertypes
+from qutebrowser.config import config, configutils
+from qutebrowser.utils import log, usertypes, urlmatch, qtutils
from qutebrowser.misc import objects
UNSET = object()
-class Base:
+class AbstractSettings:
- """Base class for QWeb(Engine)Settings wrappers."""
+ """Abstract base class for settings set via QWeb(Engine)Settings."""
- def __init__(self, default=UNSET):
- self._default = default
+ _ATTRIBUTES = None
+ _FONT_SIZES = None
+ _FONT_FAMILIES = None
+ _FONT_TO_QFONT = None
- def _get_global_settings(self):
- """Get a list of global QWeb(Engine)Settings to use."""
- raise NotImplementedError
+ def __init__(self, settings):
+ self._settings = settings
- def _get_settings(self, settings):
- """Get a list of QWeb(Engine)Settings objects to use.
+ def set_attribute(self, name, value):
+ """Set the given QWebSettings/QWebEngineSettings attribute.
- Args:
- settings: The QWeb(Engine)Settings instance to use, or None to use
- the global instance.
+ If the value is configutils.UNSET, the value is reset instead.
Return:
- A list of QWeb(Engine)Settings objects. The first one should be
- used for reading.
- """
- if settings is None:
- return self._get_global_settings()
- else:
- return [settings]
-
- def set(self, value, settings=None):
- """Set the value of this setting.
-
- Args:
- value: The value to set, or None to restore the default.
- settings: The QWeb(Engine)Settings instance to use, or None to use
- the global instance.
+ True if there was a change, False otherwise.
"""
- if value is None:
- self.set_default(settings=settings)
- else:
- self._set(value, settings=settings)
+ old_value = self.test_attribute(name)
- def set_default(self, settings=None):
- """Set the default value for this setting.
-
- Not implemented for most settings.
- """
- if self._default is UNSET:
- raise ValueError("No default set for {!r}".format(self))
- else:
- self._set(self._default, settings=settings)
+ for attribute in self._ATTRIBUTES[name]:
+ if value is configutils.UNSET:
+ self._settings.resetAttribute(attribute)
+ new_value = self.test_attribute(name)
+ else:
+ self._settings.setAttribute(attribute, value)
+ new_value = value
- def _set(self, value, settings):
- """Inner function to set the value of this setting.
+ return old_value != new_value
- Must be overridden by subclasses.
+ def test_attribute(self, name):
+ """Get the value for the given attribute.
- Args:
- value: The value to set.
- settings: The QWeb(Engine)Settings instance to use, or None to use
- the global instance.
+ If the setting resolves to a list of attributes, only the first
+ attribute is tested.
"""
- raise NotImplementedError
-
-
-class Attribute(Base):
-
- """A setting set via QWeb(Engine)Settings::setAttribute.
-
- Attributes:
- self._attributes: A list of QWeb(Engine)Settings::WebAttribute members.
- """
-
- ENUM_BASE = None
-
- def __init__(self, *attributes, default=UNSET):
- super().__init__(default=default)
- self._attributes = list(attributes)
-
- def __repr__(self):
- attributes = [debug.qenum_key(self.ENUM_BASE, attr)
- for attr in self._attributes]
- return utils.get_repr(self, attributes=attributes, constructor=True)
-
- def _set(self, value, settings=None):
- for obj in self._get_settings(settings):
- for attribute in self._attributes:
- obj.setAttribute(attribute, value)
-
-
-class Setter(Base):
-
- """A setting set via a QWeb(Engine)Settings setter method.
+ return self._settings.testAttribute(self._ATTRIBUTES[name][0])
- This will pass the QWeb(Engine)Settings instance ("self") as first argument
- to the methods, so self._setter is the *unbound* method.
+ def set_font_size(self, name, value):
+ """Set the given QWebSettings/QWebEngineSettings font size.
- Attributes:
- _setter: The unbound QWeb(Engine)Settings method to set this value.
- _args: An iterable of the arguments to pass to the setter (before the
- value).
- _unpack: Whether to unpack args (True) or pass them directly (False).
- """
-
- def __init__(self, setter, args=(), unpack=False, default=UNSET):
- super().__init__(default=default)
- self._setter = setter
- self._args = args
- self._unpack = unpack
-
- def __repr__(self):
- return utils.get_repr(self, setter=self._setter, args=self._args,
- unpack=self._unpack, constructor=True)
-
- def _set(self, value, settings=None):
- for obj in self._get_settings(settings):
- args = [obj]
- args.extend(self._args)
- if self._unpack:
- args.extend(value)
- else:
- args.append(value)
- self._setter(*args)
+ Return:
+ True if there was a change, False otherwise.
+ """
+ assert value is not configutils.UNSET
+ family = self._FONT_SIZES[name]
+ old_value = self._settings.fontSize(family)
+ self._settings.setFontSize(family, value)
+ return old_value != value
+ def set_font_family(self, name, value):
+ """Set the given QWebSettings/QWebEngineSettings font family.
-class StaticSetter(Setter):
+ With None (the default), QFont is used to get the default font for the
+ family.
- """A setting set via a static QWeb(Engine)Settings method.
+ Return:
+ True if there was a change, False otherwise.
+ """
+ assert value is not configutils.UNSET
+ family = self._FONT_FAMILIES[name]
+ if value is None:
+ font = QFont()
+ font.setStyleHint(self._FONT_TO_QFONT[family])
+ value = font.defaultFamily()
- self._setter is the *bound* method.
- """
+ old_value = self._settings.fontFamily(family)
+ self._settings.setFontFamily(family, value)
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with StaticSetters!")
- args = list(self._args)
- if self._unpack:
- args.extend(value)
- else:
- args.append(value)
- self._setter(*args)
+ return value != old_value
+ def set_default_text_encoding(self, encoding):
+ """Set the default text encoding to use.
-class FontFamilySetter(Setter):
+ Return:
+ True if there was a change, False otherwise.
+ """
+ assert encoding is not configutils.UNSET
+ old_value = self._settings.defaultTextEncoding()
+ self._settings.setDefaultTextEncoding(encoding)
+ return old_value != encoding
- """A setter for a font family.
+ def _update_setting(self, setting, value):
+ """Update the given setting/value.
- Gets the default value from QFont.
- """
+ Unknown settings are ignored.
- def __init__(self, setter, font, qfont):
- super().__init__(setter=setter, args=[font])
- self._qfont = qfont
+ Return:
+ True if there was a change, False otherwise.
+ """
+ if setting in self._ATTRIBUTES:
+ return self.set_attribute(setting, value)
+ elif setting in self._FONT_SIZES:
+ return self.set_font_size(setting, value)
+ elif setting in self._FONT_FAMILIES:
+ return self.set_font_family(setting, value)
+ elif setting == 'content.default_encoding':
+ return self.set_default_text_encoding(value)
+ return False
+
+ def update_setting(self, setting):
+ """Update the given setting."""
+ value = config.instance.get(setting)
+ self._update_setting(setting, value)
+
+ def update_for_url(self, url):
+ """Update settings customized for the given tab.
- def set_default(self, settings=None):
- font = QFont()
- font.setStyleHint(self._qfont)
- value = font.defaultFamily()
- self._set(value, settings=settings)
+ Return:
+ A set of settings which actually changed.
+ """
+ qtutils.ensure_valid(url)
+ changed_settings = set()
+ for values in config.instance:
+ if not values.opt.supports_pattern:
+ continue
+ value = values.get_for_url(url, fallback=False)
-def init_mappings(mappings):
- """Initialize all settings based on a settings mapping."""
- for option, mapping in mappings.items():
- value = config.instance.get(option)
- log.config.vdebug("Setting {} to {!r}".format(option, value))
- mapping.set(value)
+ changed = self._update_setting(values.opt.name, value)
+ if changed:
+ log.config.debug("Changed for {}: {} = {}".format(
+ url.toDisplayString(), values.opt.name, value))
+ changed_settings.add(values.opt.name)
+ return changed_settings
-def update_mappings(mappings, option):
- """Update global settings when QWeb(Engine)Settings changed."""
- try:
- mapping = mappings[option]
- except KeyError:
- return
- value = config.instance.get(option)
- mapping.set(value)
+ def init_settings(self):
+ """Set all supported settings correctly."""
+ for setting in (list(self._ATTRIBUTES) + list(self._FONT_SIZES) +
+ list(self._FONT_FAMILIES)):
+ self.update_setting(setting)
def init(args):
@@ -217,6 +173,11 @@ def init(args):
from qutebrowser.browser.webkit import webkitsettings
webkitsettings.init(args)
+ # Make sure special URLs always get JS support
+ for pattern in ['file://*', 'chrome://*/*', 'qute://*/*']:
+ config.instance.set_obj('content.javascript.enabled', True,
+ pattern=urlmatch.UrlPattern(pattern))
+
def shutdown():
"""Shut down QWeb(Engine)Settings."""
diff --git a/qutebrowser/html/settings.html b/qutebrowser/html/settings.html
index b370c0d91..62b424a59 100644
--- a/qutebrowser/html/settings.html
+++ b/qutebrowser/html/settings.html
@@ -33,7 +33,7 @@ input { width: 98%; }
<th>Setting</th>
<th>Value</th>
</tr>
- {% for option in configdata.DATA.values() %}
+ {% for option in configdata.DATA.values() if not option.no_autoconfig %}
<tr>
<!-- FIXME: convert to string properly -->
<td class="setting">{{ option.name }} (Current: {{ confget(option.name) | string |truncate(100) }})
diff --git a/qutebrowser/html/tabs.html b/qutebrowser/html/tabs.html
new file mode 100644
index 000000000..fff8bdca3
--- /dev/null
+++ b/qutebrowser/html/tabs.html
@@ -0,0 +1,58 @@
+{% extends "styled.html" %}
+
+{% block style %}
+{{super()}}
+h1 {
+ margin-bottom: 10px;
+}
+
+.url a {
+ color: #444;
+}
+
+th {
+ text-align: left;
+}
+
+.qmarks .name {
+ padding-left: 5px;
+}
+
+.empty-msg {
+ background-color: #f8f8f8;
+ color: #444;
+ display: inline-block;
+ text-align: center;
+ width: 100%;
+}
+
+details {
+ margin-top: 20px;
+}
+{% endblock %}
+
+{% block content %}
+
+<h1>Tab list</h1>
+{% for win_id, tabs in tab_list_by_window.items() %}
+<h2>Window {{ win_id }}</h2>
+<table class="tabs_win{{win_id}}">
+ <tbody>
+ {% for name, url in tabs %}
+ <tr>
+ <td class="name"><a href="{{url}}">{{name}}</a></td>
+ <td class="url"><a href="{{url}}">{{url}}</a></td>
+ </tr>
+ {% endfor %}
+ </tbody>
+</table>
+{% endfor %}
+<details>
+ <summary>Raw list</summary>
+<code>
+{% for win_id, tabs in tab_list_by_window.items() %}{% for name, url in tabs %}
+{{url}}</br>{% endfor %}
+{% endfor %}
+</code>
+</details>
+{% endblock %}
diff --git a/qutebrowser/html/undef_error.html b/qutebrowser/html/undef_error.html
deleted file mode 100644
index 55a47ca95..000000000
--- a/qutebrowser/html/undef_error.html
+++ /dev/null
@@ -1,22 +0,0 @@
-<!DOCTYPE html>
-<!--
-vim: ft=html fileencoding=utf-8 sts=4 sw=4 et:
--->
-
-<html>
- <head>
- <meta charset="utf-8">
- <title>Error while rendering HTML</title>
- </head>
- <body>
- <h1>Error while rendering internal qutebrowser page</h1>
- <p>There was an error while rendering {pagename}.</p>
-
- <p>This most likely happened because you updated qutebrowser but didn't restart yet.</p>
-
- <p>If you believe this isn't the case and this is a bug, please do :report.<p>
-
- <h2>Traceback</h2>
- <pre>{traceback}</pre>
- </body>
-</html>
diff --git a/qutebrowser/javascript/greasemonkey_wrapper.js b/qutebrowser/javascript/greasemonkey_wrapper.js
index 2d36220dc..71266755a 100644
--- a/qutebrowser/javascript/greasemonkey_wrapper.js
+++ b/qutebrowser/javascript/greasemonkey_wrapper.js
@@ -110,6 +110,44 @@
}
}
+ // Stub these two so that the gm4 polyfill script doesn't try to
+ // create broken versions as attributes of window.
+ function GM_getResourceText(caption, commandFunc, accessKey) {
+ console.error(`${GM_info.script.name} called unimplemented GM_getResourceText`);
+ }
+
+ function GM_registerMenuCommand(caption, commandFunc, accessKey) {
+ console.error(`${GM_info.script.name} called unimplemented GM_registerMenuCommand`);
+ }
+
+ // Mock the greasemonkey 4.0 async API.
+ const GM = {};
+ GM.info = GM_info;
+ const entries = {
+ 'log': GM_log,
+ 'addStyle': GM_addStyle,
+ 'deleteValue': GM_deleteValue,
+ 'getValue': GM_getValue,
+ 'listValues': GM_listValues,
+ 'openInTab': GM_openInTab,
+ 'setValue': GM_setValue,
+ 'xmlHttpRequest': GM_xmlhttpRequest,
+ }
+ for (newKey in entries) {
+ let old = entries[newKey];
+ if (old && (typeof GM[newKey] == 'undefined')) {
+ GM[newKey] = function(...args) {
+ return new Promise((resolve, reject) => {
+ try {
+ resolve(old(...args));
+ } catch (e) {
+ reject(e);
+ }
+ });
+ };
+ }
+ };
+
const unsafeWindow = window;
// ====== The actual user script source ====== //
diff --git a/qutebrowser/javascript/webelem.js b/qutebrowser/javascript/webelem.js
index d635de412..f7ab0f636 100644
--- a/qutebrowser/javascript/webelem.js
+++ b/qutebrowser/javascript/webelem.js
@@ -74,9 +74,8 @@ window._qutebrowser.webelem = (function() {
try {
return elem.selectionStart;
} catch (err) {
- if (err instanceof (frame
- ? frame.DOMException
- : DOMException) &&
+ if ((err instanceof DOMException ||
+ (frame && err instanceof frame.DOMException)) &&
err.name === "InvalidStateError") {
// nothing to do, caret_position is already null
} else {
@@ -331,13 +330,13 @@ window._qutebrowser.webelem = (function() {
// Function for returning a selection to python (so we can click it)
funcs.find_selected_link = () => {
- const elem = window.getSelection().anchorNode;
+ const elem = window.getSelection().baseNode;
if (elem) {
return serialize_elem(elem.parentNode);
}
const serialized_frame_elem = run_frames((frame) => {
- const node = frame.window.getSelection().anchorNode;
+ const node = frame.window.getSelection().baseNode;
if (node) {
return serialize_elem(node.parentNode, frame);
}
diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py
index 89120f922..f0f2c6f28 100644
--- a/qutebrowser/keyinput/basekeyparser.py
+++ b/qutebrowser/keyinput/basekeyparser.py
@@ -19,14 +19,12 @@
"""Base class for vim-like key sequence parser."""
-import enum
-import re
-import unicodedata
-
from PyQt5.QtCore import pyqtSignal, QObject
+from PyQt5.QtGui import QKeySequence
from qutebrowser.config import config
from qutebrowser.utils import usertypes, log, utils
+from qutebrowser.keyinput import keyutils
class BaseKeyParser(QObject):
@@ -43,24 +41,16 @@ class BaseKeyParser(QObject):
definitive: Keychain matches exactly.
none: No more matches possible.
- Types: type of a key binding.
- chain: execute() was called via a chain-like key binding
- special: execute() was called via a special key binding
-
do_log: Whether to log keypresses or not.
passthrough: Whether unbound keys should be passed through with this
handler.
Attributes:
bindings: Bound key bindings
- special_bindings: Bound special bindings (<Foo>).
_win_id: The window ID this keyparser is associated with.
- _warn_on_keychains: Whether a warning should be logged when binding
- keychains in a section which does not support them.
- _keystring: The currently entered key sequence
+ _sequence: The currently entered key sequence
_modename: The name of the input mode associated with this keyparser.
_supports_count: Whether count is supported
- _supports_chains: Whether keychains are supported
Signals:
keystring_updated: Emitted when the keystring is updated.
@@ -76,27 +66,18 @@ class BaseKeyParser(QObject):
do_log = True
passthrough = False
- Match = enum.Enum('Match', ['partial', 'definitive', 'other', 'none'])
- Type = enum.Enum('Type', ['chain', 'special'])
-
- def __init__(self, win_id, parent=None, supports_count=None,
- supports_chains=False):
+ def __init__(self, win_id, parent=None, supports_count=True):
super().__init__(parent)
self._win_id = win_id
self._modename = None
- self._keystring = ''
- if supports_count is None:
- supports_count = supports_chains
+ self._sequence = keyutils.KeySequence()
+ self._count = ''
self._supports_count = supports_count
- self._supports_chains = supports_chains
- self._warn_on_keychains = True
self.bindings = {}
- self.special_bindings = {}
config.instance.changed.connect(self._on_config_changed)
def __repr__(self):
- return utils.get_repr(self, supports_count=self._supports_count,
- supports_chains=self._supports_chains)
+ return utils.get_repr(self, supports_count=self._supports_count)
def _debug_log(self, message):
"""Log a message to the debug log if logging is active.
@@ -107,62 +88,66 @@ class BaseKeyParser(QObject):
if self.do_log:
log.keyboard.debug(message)
- def _handle_special_key(self, e):
- """Handle a new keypress with special keys (<Foo>).
-
- Return True if the keypress has been handled, and False if not.
-
- Args:
- e: the KeyPressEvent from Qt.
-
- Return:
- True if event has been handled, False otherwise.
- """
- binding = utils.keyevent_to_string(e)
- if binding is None:
- self._debug_log("Ignoring only-modifier keyeevent.")
- return False
-
- if binding not in self.special_bindings:
- key_mappings = config.val.bindings.key_mappings
- try:
- binding = key_mappings['<{}>'.format(binding)][1:-1]
- except KeyError:
- pass
-
- try:
- cmdstr = self.special_bindings[binding]
- except KeyError:
- self._debug_log("No special binding found for {}.".format(binding))
- return False
- count, _command = self._split_count(self._keystring)
- self.execute(cmdstr, self.Type.special, count)
- self.clear_keystring()
- return True
-
- def _split_count(self, keystring):
- """Get count and command from the current keystring.
+ def _match_key(self, sequence):
+ """Try to match a given keystring with any bound keychain.
Args:
- keystring: The key string to split.
+ sequence: The command string to find.
Return:
- A (count, command) tuple.
+ A tuple (matchtype, binding).
+ matchtype: Match.definitive, Match.partial or Match.none.
+ binding: - None with Match.partial/Match.none.
+ - The found binding with Match.definitive.
"""
- if self._supports_count:
- (countstr, cmd_input) = re.fullmatch(r'(\d*)(.*)',
- keystring).groups()
- count = int(countstr) if countstr else None
- if count == 0 and not cmd_input:
- cmd_input = keystring
- count = None
- else:
- cmd_input = keystring
- count = None
- return count, cmd_input
-
- def _handle_single_key(self, e):
- """Handle a new keypress with a single key (no modifiers).
+ assert sequence
+ assert not isinstance(sequence, str)
+ result = QKeySequence.NoMatch
+
+ for seq, cmd in self.bindings.items():
+ assert not isinstance(seq, str), seq
+ match = sequence.matches(seq)
+ if match == QKeySequence.ExactMatch:
+ return match, cmd
+ elif match == QKeySequence.PartialMatch:
+ result = QKeySequence.PartialMatch
+
+ return result, None
+
+ def _match_without_modifiers(self, sequence):
+ """Try to match a key with optional modifiers stripped."""
+ self._debug_log("Trying match without modifiers")
+ sequence = sequence.strip_modifiers()
+ match, binding = self._match_key(sequence)
+ return match, binding, sequence
+
+ def _match_key_mapping(self, sequence):
+ """Try to match a key in bindings.key_mappings."""
+ self._debug_log("Trying match with key_mappings")
+ mapped = sequence.with_mappings(config.val.bindings.key_mappings)
+ if sequence != mapped:
+ self._debug_log("Mapped {} -> {}".format(
+ sequence, mapped))
+ match, binding = self._match_key(mapped)
+ sequence = mapped
+ return match, binding, sequence
+ return QKeySequence.NoMatch, None, sequence
+
+ def _match_count(self, sequence, dry_run):
+ """Try to match a key as count."""
+ txt = str(sequence[-1]) # To account for sequences changed above.
+ if (txt.isdigit() and self._supports_count and
+ not (not self._count and txt == '0')):
+ self._debug_log("Trying match as count")
+ assert len(txt) == 1, txt
+ if not dry_run:
+ self._count += txt
+ self.keystring_updated.emit(self._count + str(self._sequence))
+ return True
+ return False
+
+ def handle(self, e, *, dry_run=False):
+ """Handle a new keypress.
Separate the keypress into count/command, then check if it matches
any possible command, and either run the command, ignore it, or
@@ -170,109 +155,62 @@ class BaseKeyParser(QObject):
Args:
e: the KeyPressEvent from Qt.
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Return:
- A self.Match member.
+ A QKeySequence match.
"""
- txt = e.text()
key = e.key()
- self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt))
+ txt = str(keyutils.KeyInfo.from_event(e))
+ self._debug_log("Got key: 0x{:x} / modifiers: 0x{:x} / text: '{}' / "
+ "dry_run {}".format(key, int(e.modifiers()), txt,
+ dry_run))
- if len(txt) == 1:
- category = unicodedata.category(txt)
- is_control_char = (category == 'Cc')
- else:
- is_control_char = False
-
- if (not txt) or is_control_char:
- self._debug_log("Ignoring, no text char")
- return self.Match.none
-
- count, cmd_input = self._split_count(self._keystring + txt)
- match, binding = self._match_key(cmd_input)
- if match == self.Match.none:
- mappings = config.val.bindings.key_mappings
- mapped = mappings.get(txt, None)
- if mapped is not None:
- txt = mapped
- count, cmd_input = self._split_count(self._keystring + txt)
- match, binding = self._match_key(cmd_input)
-
- self._keystring += txt
- if match == self.Match.definitive:
+ if keyutils.is_modifier_key(key):
+ self._debug_log("Ignoring, only modifier")
+ return QKeySequence.NoMatch
+
+ try:
+ sequence = self._sequence.append_event(e)
+ except keyutils.KeyParseError as ex:
+ self._debug_log("{} Aborting keychain.".format(ex))
+ self.clear_keystring()
+ return QKeySequence.NoMatch
+
+ match, binding = self._match_key(sequence)
+ if match == QKeySequence.NoMatch:
+ match, binding, sequence = self._match_without_modifiers(sequence)
+ if match == QKeySequence.NoMatch:
+ match, binding, sequence = self._match_key_mapping(sequence)
+ if match == QKeySequence.NoMatch:
+ was_count = self._match_count(sequence, dry_run)
+ if was_count:
+ return QKeySequence.ExactMatch
+
+ if dry_run:
+ return match
+
+ self._sequence = sequence
+
+ if match == QKeySequence.ExactMatch:
self._debug_log("Definitive match for '{}'.".format(
- self._keystring))
+ sequence))
+ count = int(self._count) if self._count else None
self.clear_keystring()
- self.execute(binding, self.Type.chain, count)
- elif match == self.Match.partial:
+ self.execute(binding, count)
+ elif match == QKeySequence.PartialMatch:
self._debug_log("No match for '{}' (added {})".format(
- self._keystring, txt))
- elif match == self.Match.none:
+ sequence, txt))
+ self.keystring_updated.emit(self._count + str(sequence))
+ elif match == QKeySequence.NoMatch:
self._debug_log("Giving up with '{}', no matches".format(
- self._keystring))
+ sequence))
self.clear_keystring()
- elif match == self.Match.other:
- pass
else:
raise utils.Unreachable("Invalid match value {!r}".format(match))
- return match
-
- def _match_key(self, cmd_input):
- """Try to match a given keystring with any bound keychain.
-
- Args:
- cmd_input: The command string to find.
-
- Return:
- A tuple (matchtype, binding).
- matchtype: Match.definitive, Match.partial or Match.none.
- binding: - None with Match.partial/Match.none.
- - The found binding with Match.definitive.
- """
- if not cmd_input:
- # Only a count, no command yet, but we handled it
- return (self.Match.other, None)
- # A (cmd_input, binding) tuple (k, v of bindings) or None.
- definitive_match = None
- partial_match = False
- # Check definitive match
- try:
- definitive_match = (cmd_input, self.bindings[cmd_input])
- except KeyError:
- pass
- # Check partial match
- for binding in self.bindings:
- if definitive_match is not None and binding == definitive_match[0]:
- # We already matched that one
- continue
- elif binding.startswith(cmd_input):
- partial_match = True
- break
- if definitive_match is not None:
- return (self.Match.definitive, definitive_match[1])
- elif partial_match:
- return (self.Match.partial, None)
- else:
- return (self.Match.none, None)
-
- def handle(self, e):
- """Handle a new keypress and call the respective handlers.
-
- Args:
- e: the KeyPressEvent from Qt
-
- Return:
- True if the event was handled, False otherwise.
- """
- handled = self._handle_special_key(e)
- if handled or not self._supports_chains:
- return handled
- match = self._handle_single_key(e)
- # don't emit twice if the keystring was cleared in self.clear_keystring
- if self._keystring:
- self.keystring_updated.emit(self._keystring)
- return match != self.Match.none
+ return match
@config.change_filter('bindings')
def _on_config_changed(self):
@@ -295,37 +233,26 @@ class BaseKeyParser(QObject):
else:
self._modename = modename
self.bindings = {}
- self.special_bindings = {}
for key, cmd in config.key_instance.get_bindings_for(modename).items():
+ assert not isinstance(key, str), key
assert cmd
- self._parse_key_command(modename, key, cmd)
-
- def _parse_key_command(self, modename, key, cmd):
- """Parse the keys and their command and store them in the object."""
- if utils.is_special_key(key):
- self.special_bindings[key[1:-1]] = cmd
- elif self._supports_chains:
self.bindings[key] = cmd
- elif self._warn_on_keychains:
- log.keyboard.warning("Ignoring keychain '{}' in mode '{}' because "
- "keychains are not supported there."
- .format(key, modename))
- def execute(self, cmdstr, keytype, count=None):
+ def execute(self, cmdstr, count=None):
"""Handle a completed keychain.
Args:
cmdstr: The command to execute as a string.
- keytype: Type.chain or Type.special
count: The count if given.
"""
raise NotImplementedError
def clear_keystring(self):
"""Clear the currently entered key sequence."""
- if self._keystring:
- self._debug_log("discarding keystring '{}'.".format(
- self._keystring))
- self._keystring = ''
- self.keystring_updated.emit(self._keystring)
+ if self._sequence:
+ self._debug_log("Clearing keystring (was: {}).".format(
+ self._sequence))
+ self._sequence = keyutils.KeySequence()
+ self._count = ''
+ self.keystring_updated.emit('')
diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py
deleted file mode 100644
index 8ae27412e..000000000
--- a/qutebrowser/keyinput/keyparser.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-
-"""Advanced keyparsers."""
-
-import traceback
-
-from qutebrowser.keyinput.basekeyparser import BaseKeyParser
-from qutebrowser.utils import message, utils
-from qutebrowser.commands import runners, cmdexc
-
-
-class CommandKeyParser(BaseKeyParser):
-
- """KeyChainParser for command bindings.
-
- Attributes:
- _commandrunner: CommandRunner instance.
- """
-
- def __init__(self, win_id, parent=None, supports_count=None,
- supports_chains=False):
- super().__init__(win_id, parent, supports_count, supports_chains)
- self._commandrunner = runners.CommandRunner(win_id)
-
- def execute(self, cmdstr, _keytype, count=None):
- try:
- self._commandrunner.run(cmdstr, count)
- except cmdexc.Error as e:
- message.error(str(e), stack=traceback.format_exc())
-
-
-class PassthroughKeyParser(CommandKeyParser):
-
- """KeyChainParser which passes through normal keys.
-
- Used for insert/passthrough modes.
-
- Attributes:
- _mode: The mode this keyparser is for.
- """
-
- do_log = False
- passthrough = True
-
- def __init__(self, win_id, mode, parent=None, warn=True):
- """Constructor.
-
- Args:
- mode: The mode this keyparser is for.
- parent: Qt parent.
- warn: Whether to warn if an ignored key was bound.
- """
- super().__init__(win_id, parent, supports_chains=False)
- self._warn_on_keychains = warn
- self._read_config(mode)
- self._mode = mode
-
- def __repr__(self):
- return utils.get_repr(self, mode=self._mode,
- warn=self._warn_on_keychains)
diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py
new file mode 100644
index 000000000..1f34fcae0
--- /dev/null
+++ b/qutebrowser/keyinput/keyutils.py
@@ -0,0 +1,558 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""Our own QKeySequence-like class and related utilities."""
+
+import itertools
+
+import attr
+from PyQt5.QtCore import Qt, QEvent
+from PyQt5.QtGui import QKeySequence, QKeyEvent
+
+from qutebrowser.utils import utils
+
+
+# Map Qt::Key values to their Qt::KeyboardModifier value.
+_MODIFIER_MAP = {
+ Qt.Key_Shift: Qt.ShiftModifier,
+ Qt.Key_Control: Qt.ControlModifier,
+ Qt.Key_Alt: Qt.AltModifier,
+ Qt.Key_Meta: Qt.MetaModifier,
+ Qt.Key_Mode_switch: Qt.GroupSwitchModifier,
+}
+
+
+def _assert_plain_key(key):
+ """Make sure this is a key without KeyboardModifiers mixed in."""
+ assert not key & Qt.KeyboardModifierMask, hex(key)
+
+
+def _assert_plain_modifier(key):
+ """Make sure this is a modifier without a key mixed in."""
+ assert not key & ~Qt.KeyboardModifierMask, hex(key)
+
+
+def _is_printable(key):
+ _assert_plain_key(key)
+ return key <= 0xff and key not in [Qt.Key_Space, 0x0]
+
+
+def is_special(key, modifiers):
+ """Check whether this key requires special key syntax."""
+ _assert_plain_key(key)
+ _assert_plain_modifier(modifiers)
+ return not (_is_printable(key) and
+ modifiers in [Qt.ShiftModifier, Qt.NoModifier])
+
+
+def is_modifier_key(key):
+ """Test whether the given key is a modifier.
+
+ This only considers keys which are part of Qt::KeyboardModifiers, i.e.
+ which would interrupt a key chain like "yY" when handled.
+ """
+ _assert_plain_key(key)
+ return key in _MODIFIER_MAP
+
+
+def _check_valid_utf8(s, data):
+ """Make sure the given string is valid UTF-8.
+
+ Makes sure there are no chars where Qt did fall back to weird UTF-16
+ surrogates.
+ """
+ try:
+ s.encode('utf-8')
+ except UnicodeEncodeError as e: # pragma: no cover
+ raise ValueError("Invalid encoding in 0x{:x} -> {}: {}"
+ .format(data, s, e))
+
+
+def _key_to_string(key):
+ """Convert a Qt::Key member to a meaningful name.
+
+ Args:
+ key: A Qt::Key member.
+
+ Return:
+ A name of the key as a string.
+ """
+ _assert_plain_key(key)
+ special_names_str = {
+ # Some keys handled in a weird way by QKeySequence::toString.
+ # See https://bugreports.qt.io/browse/QTBUG-40030
+ # Most are unlikely to be ever needed, but you never know ;)
+ # For dead/combining keys, we return the corresponding non-combining
+ # key, as that's easier to add to the config.
+
+ 'Super_L': 'Super L',
+ 'Super_R': 'Super R',
+ 'Hyper_L': 'Hyper L',
+ 'Hyper_R': 'Hyper R',
+ 'Direction_L': 'Direction L',
+ 'Direction_R': 'Direction R',
+
+ 'Shift': 'Shift',
+ 'Control': 'Control',
+ 'Meta': 'Meta',
+ 'Alt': 'Alt',
+
+ 'AltGr': 'AltGr',
+ 'Multi_key': 'Multi key',
+ 'SingleCandidate': 'Single Candidate',
+ 'Mode_switch': 'Mode switch',
+ 'Dead_Grave': '`',
+ 'Dead_Acute': '´',
+ 'Dead_Circumflex': '^',
+ 'Dead_Tilde': '~',
+ 'Dead_Macron': '¯',
+ 'Dead_Breve': '˘',
+ 'Dead_Abovedot': '˙',
+ 'Dead_Diaeresis': '¨',
+ 'Dead_Abovering': '˚',
+ 'Dead_Doubleacute': '˝',
+ 'Dead_Caron': 'ˇ',
+ 'Dead_Cedilla': '¸',
+ 'Dead_Ogonek': '˛',
+ 'Dead_Iota': 'Iota',
+ 'Dead_Voiced_Sound': 'Voiced Sound',
+ 'Dead_Semivoiced_Sound': 'Semivoiced Sound',
+ 'Dead_Belowdot': 'Belowdot',
+ 'Dead_Hook': 'Hook',
+ 'Dead_Horn': 'Horn',
+
+ 'Memo': 'Memo',
+ 'ToDoList': 'To Do List',
+ 'Calendar': 'Calendar',
+ 'ContrastAdjust': 'Contrast Adjust',
+ 'LaunchG': 'Launch (G)',
+ 'LaunchH': 'Launch (H)',
+
+ 'MediaLast': 'Media Last',
+
+ 'unknown': 'Unknown',
+
+ # For some keys, we just want a different name
+ 'Escape': 'Escape',
+ }
+ # We now build our real special_names dict from the string mapping above.
+ # The reason we don't do this directly is that certain Qt versions don't
+ # have all the keys, so we want to ignore AttributeErrors.
+ special_names = {}
+ for k, v in special_names_str.items():
+ try:
+ special_names[getattr(Qt, 'Key_' + k)] = v
+ except AttributeError:
+ pass
+ special_names[0x0] = 'nil'
+
+ if key in special_names:
+ return special_names[key]
+
+ result = QKeySequence(key).toString()
+ _check_valid_utf8(result, key)
+ return result
+
+
+def _modifiers_to_string(modifiers):
+ """Convert the given Qt::KeyboardModifiers to a string.
+
+ Handles Qt.GroupSwitchModifier because Qt doesn't handle that as a
+ modifier.
+ """
+ _assert_plain_modifier(modifiers)
+ if modifiers & Qt.GroupSwitchModifier:
+ modifiers &= ~Qt.GroupSwitchModifier
+ result = 'AltGr+'
+ else:
+ result = ''
+
+ result += QKeySequence(modifiers).toString()
+
+ _check_valid_utf8(result, modifiers)
+ return result
+
+
+class KeyParseError(Exception):
+
+ """Raised by _parse_single_key/parse_keystring on parse errors."""
+
+ def __init__(self, keystr, error):
+ if keystr is None:
+ msg = "Could not parse keystring: {}".format(error)
+ else:
+ msg = "Could not parse {!r}: {}".format(keystr, error)
+ super().__init__(msg)
+
+
+def _parse_keystring(keystr):
+ key = ''
+ special = False
+ for c in keystr:
+ if c == '>':
+ if special:
+ yield _parse_special_key(key)
+ key = ''
+ special = False
+ else:
+ yield '>'
+ assert not key, key
+ elif c == '<':
+ special = True
+ elif special:
+ key += c
+ else:
+ yield _parse_single_key(c)
+ if special:
+ yield '<'
+ for c in key:
+ yield _parse_single_key(c)
+
+
+def _parse_special_key(keystr):
+ """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q.
+
+ Args:
+ keystr: The key combination as a string.
+
+ Return:
+ The normalized keystring.
+ """
+ keystr = keystr.lower()
+ replacements = (
+ ('control', 'ctrl'),
+ ('windows', 'meta'),
+ ('mod1', 'alt'),
+ ('mod4', 'meta'),
+ ('less', '<'),
+ ('greater', '>'),
+ )
+ for (orig, repl) in replacements:
+ keystr = keystr.replace(orig, repl)
+
+ for mod in ['ctrl', 'meta', 'alt', 'shift', 'num']:
+ keystr = keystr.replace(mod + '-', mod + '+')
+ return keystr
+
+
+def _parse_single_key(keystr):
+ """Get a keystring for QKeySequence for a single key."""
+ return 'Shift+' + keystr if keystr.isupper() else keystr
+
+
+@attr.s
+class KeyInfo:
+
+ """A key with optional modifiers.
+
+ Attributes:
+ key: A Qt::Key member.
+ modifiers: A Qt::KeyboardModifiers enum value.
+ """
+
+ key = attr.ib()
+ modifiers = attr.ib()
+
+ @classmethod
+ def from_event(cls, e):
+ return cls(e.key(), e.modifiers())
+
+ def __str__(self):
+ """Convert this KeyInfo to a meaningful name.
+
+ Return:
+ A name of the key (combination) as a string.
+ """
+ key_string = _key_to_string(self.key)
+ modifiers = int(self.modifiers)
+
+ if self.key in _MODIFIER_MAP:
+ # Don't return e.g. <Shift+Shift>
+ modifiers &= ~_MODIFIER_MAP[self.key]
+ elif _is_printable(self.key):
+ # "normal" binding
+ if not key_string: # pragma: no cover
+ raise ValueError("Got empty string for key 0x{:x}!"
+ .format(self.key))
+
+ assert len(key_string) == 1, key_string
+ if self.modifiers == Qt.ShiftModifier:
+ assert not is_special(self.key, self.modifiers)
+ return key_string.upper()
+ elif self.modifiers == Qt.NoModifier:
+ assert not is_special(self.key, self.modifiers)
+ return key_string.lower()
+ else:
+ # Use special binding syntax, but <Ctrl-a> instead of <Ctrl-A>
+ key_string = key_string.lower()
+
+ # "special" binding
+ assert is_special(self.key, self.modifiers)
+ modifier_string = _modifiers_to_string(modifiers)
+ return '<{}{}>'.format(modifier_string, key_string)
+
+ def text(self):
+ """Get the text which would be displayed when pressing this key."""
+ control = {
+ Qt.Key_Space: ' ',
+ Qt.Key_Tab: '\t',
+ Qt.Key_Backspace: '\b',
+ Qt.Key_Return: '\r',
+ Qt.Key_Enter: '\r',
+ Qt.Key_Escape: '\x1b',
+ }
+
+ if self.key in control:
+ return control[self.key]
+ elif not _is_printable(self.key):
+ return ''
+
+ text = QKeySequence(self.key).toString()
+ if not self.modifiers & Qt.ShiftModifier:
+ text = text.lower()
+ return text
+
+ def to_event(self, typ=QEvent.KeyPress):
+ """Get a QKeyEvent from this KeyInfo."""
+ return QKeyEvent(typ, self.key, self.modifiers, self.text())
+
+ def to_int(self):
+ """Get the key as an integer (with key/modifiers)."""
+ return int(self.key) | int(self.modifiers)
+
+
+class KeySequence:
+
+ """A sequence of key presses.
+
+ This internally uses chained QKeySequence objects and exposes a nicer
+ interface over it.
+
+ NOTE: While private members of this class are in theory mutable, they must
+ not be mutated in order to ensure consistent hashing.
+
+ Attributes:
+ _sequences: A list of QKeySequence
+
+ Class attributes:
+ _MAX_LEN: The maximum amount of keys in a QKeySequence.
+ """
+
+ _MAX_LEN = 4
+
+ def __init__(self, *keys):
+ self._sequences = []
+ for sub in utils.chunk(keys, self._MAX_LEN):
+ sequence = QKeySequence(*sub)
+ self._sequences.append(sequence)
+ if keys:
+ assert self
+ self._validate()
+
+ def __str__(self):
+ parts = []
+ for info in self:
+ parts.append(str(info))
+ return ''.join(parts)
+
+ def __iter__(self):
+ """Iterate over KeyInfo objects."""
+ for key_and_modifiers in self._iter_keys():
+ key = int(key_and_modifiers) & ~Qt.KeyboardModifierMask
+ modifiers = Qt.KeyboardModifiers(int(key_and_modifiers) &
+ Qt.KeyboardModifierMask)
+ yield KeyInfo(key=key, modifiers=modifiers)
+
+ def __repr__(self):
+ return utils.get_repr(self, keys=str(self))
+
+ def __lt__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences < other._sequences
+
+ def __gt__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences > other._sequences
+
+ def __le__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences <= other._sequences
+
+ def __ge__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences >= other._sequences
+
+ def __eq__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences == other._sequences
+
+ def __ne__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences != other._sequences
+
+ def __hash__(self):
+ return hash(tuple(self._sequences))
+
+ def __len__(self):
+ return sum(len(seq) for seq in self._sequences)
+
+ def __bool__(self):
+ return bool(self._sequences)
+
+ def __getitem__(self, item):
+ if isinstance(item, slice):
+ keys = list(self._iter_keys())
+ return self.__class__(*keys[item])
+ else:
+ infos = list(self)
+ return infos[item]
+
+ def _iter_keys(self):
+ return itertools.chain.from_iterable(self._sequences)
+
+ def _validate(self, keystr=None):
+ for info in self:
+ if info.key < Qt.Key_Space or info.key >= Qt.Key_unknown:
+ raise KeyParseError(keystr, "Got invalid key!")
+
+ for seq in self._sequences:
+ if not seq:
+ raise KeyParseError(keystr, "Got invalid key!")
+
+ def matches(self, other):
+ """Check whether the given KeySequence matches with this one.
+
+ We store multiple QKeySequences with <= 4 keys each, so we need to
+ match those pair-wise, and account for an unequal amount of sequences
+ as well.
+ """
+ # pylint: disable=protected-access
+
+ if len(self._sequences) > len(other._sequences):
+ # If we entered more sequences than there are in the config,
+ # there's no way there can be a match.
+ return QKeySequence.NoMatch
+
+ for entered, configured in zip(self._sequences, other._sequences):
+ # If we get NoMatch/PartialMatch in a sequence, we can abort there.
+ match = entered.matches(configured)
+ if match != QKeySequence.ExactMatch:
+ return match
+
+ # We checked all common sequences and they had an ExactMatch.
+ #
+ # If there's still more sequences configured than entered, that's a
+ # PartialMatch, as more keypresses can still follow and new sequences
+ # will appear which we didn't check above.
+ #
+ # If there's the same amount of sequences configured and entered,
+ # that's an EqualMatch.
+ if len(self._sequences) == len(other._sequences):
+ return QKeySequence.ExactMatch
+ elif len(self._sequences) < len(other._sequences):
+ return QKeySequence.PartialMatch
+ else:
+ raise utils.Unreachable("self={!r} other={!r}".format(self, other))
+
+ def append_event(self, ev):
+ """Create a new KeySequence object with the given QKeyEvent added."""
+ key = ev.key()
+ modifiers = ev.modifiers()
+
+ _assert_plain_key(key)
+ _assert_plain_modifier(modifiers)
+
+ if key == 0x0:
+ raise KeyParseError(None, "Got nil key!")
+
+ # We always remove Qt.GroupSwitchModifier because QKeySequence has no
+ # way to mention that in a binding anyways...
+ modifiers &= ~Qt.GroupSwitchModifier
+
+ # We change Qt.Key_Backtab to Key_Tab here because nobody would
+ # configure "Shift-Backtab" in their config.
+ if modifiers & Qt.ShiftModifier and key == Qt.Key_Backtab:
+ key = Qt.Key_Tab
+
+ # We don't care about a shift modifier with symbols (Shift-: should
+ # match a : binding even though we typed it with a shift on an
+ # US-keyboard)
+ #
+ # However, we *do* care about Shift being involved if we got an
+ # upper-case letter, as Shift-A should match a Shift-A binding, but not
+ # an "a" binding.
+ #
+ # In addition, Shift also *is* relevant when other modifiers are
+ # involved. Shift-Ctrl-X should not be equivalent to Ctrl-X.
+ if (modifiers == Qt.ShiftModifier and
+ _is_printable(ev.key()) and
+ not ev.text().isupper()):
+ modifiers = Qt.KeyboardModifiers()
+
+ # On macOS, swap Ctrl and Meta back
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-51293
+ if utils.is_mac:
+ if modifiers & Qt.ControlModifier and modifiers & Qt.MetaModifier:
+ pass
+ elif modifiers & Qt.ControlModifier:
+ modifiers &= ~Qt.ControlModifier
+ modifiers |= Qt.MetaModifier
+ elif modifiers & Qt.MetaModifier:
+ modifiers &= ~Qt.MetaModifier
+ modifiers |= Qt.ControlModifier
+
+ keys = list(self._iter_keys())
+ keys.append(key | int(modifiers))
+
+ return self.__class__(*keys)
+
+ def strip_modifiers(self):
+ """Strip optional modifiers from keys."""
+ modifiers = Qt.KeypadModifier
+ keys = [key & ~modifiers for key in self._iter_keys()]
+ return self.__class__(*keys)
+
+ def with_mappings(self, mappings):
+ """Get a new KeySequence with the given mappings applied."""
+ keys = []
+ for key in self._iter_keys():
+ key_seq = KeySequence(key)
+ if key_seq in mappings:
+ new_seq = mappings[key_seq]
+ assert len(new_seq) == 1
+ key = new_seq[0].to_int()
+ keys.append(key)
+ return self.__class__(*keys)
+
+ @classmethod
+ def parse(cls, keystr):
+ """Parse a keystring like <Ctrl-x> or xyz and return a KeySequence."""
+ # pylint: disable=protected-access
+ new = cls()
+ strings = list(_parse_keystring(keystr))
+ for sub in utils.chunk(strings, cls._MAX_LEN):
+ sequence = QKeySequence(', '.join(sub))
+ new._sequences.append(sequence)
+
+ if keystr:
+ assert new, keystr
+
+ # pylint: disable=protected-access
+ new._validate(keystr)
+ return new
diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py
index 4e9d78fb0..ffe780333 100644
--- a/qutebrowser/keyinput/modeman.py
+++ b/qutebrowser/keyinput/modeman.py
@@ -25,7 +25,7 @@ import attr
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QObject, QEvent
from PyQt5.QtWidgets import QApplication
-from qutebrowser.keyinput import modeparsers, keyparser
+from qutebrowser.keyinput import modeparsers
from qutebrowser.config import config
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.utils import usertypes, log, objreg, utils
@@ -68,24 +68,30 @@ def init(win_id, parent):
modeman = ModeManager(win_id, parent)
objreg.register('mode-manager', modeman, scope='window', window=win_id)
keyparsers = {
- KM.normal: modeparsers.NormalKeyParser(win_id, modeman),
- KM.hint: modeparsers.HintKeyParser(win_id, modeman),
- KM.insert: keyparser.PassthroughKeyParser(win_id, 'insert', modeman),
- KM.passthrough: keyparser.PassthroughKeyParser(win_id, 'passthrough',
- modeman),
- KM.command: keyparser.PassthroughKeyParser(win_id, 'command', modeman),
- KM.prompt: keyparser.PassthroughKeyParser(win_id, 'prompt', modeman,
- warn=False),
- KM.yesno: modeparsers.PromptKeyParser(win_id, modeman),
- KM.caret: modeparsers.CaretKeyParser(win_id, modeman),
- KM.set_mark: modeparsers.RegisterKeyParser(win_id, KM.set_mark,
- modeman),
- KM.jump_mark: modeparsers.RegisterKeyParser(win_id, KM.jump_mark,
- modeman),
- KM.record_macro: modeparsers.RegisterKeyParser(win_id, KM.record_macro,
- modeman),
- KM.run_macro: modeparsers.RegisterKeyParser(win_id, KM.run_macro,
- modeman),
+ KM.normal:
+ modeparsers.NormalKeyParser(win_id, modeman),
+ KM.hint:
+ modeparsers.HintKeyParser(win_id, modeman),
+ KM.insert:
+ modeparsers.PassthroughKeyParser(win_id, 'insert', modeman),
+ KM.passthrough:
+ modeparsers.PassthroughKeyParser(win_id, 'passthrough', modeman),
+ KM.command:
+ modeparsers.PassthroughKeyParser(win_id, 'command', modeman),
+ KM.prompt:
+ modeparsers.PassthroughKeyParser(win_id, 'prompt', modeman),
+ KM.yesno:
+ modeparsers.PromptKeyParser(win_id, modeman),
+ KM.caret:
+ modeparsers.CaretKeyParser(win_id, modeman),
+ KM.set_mark:
+ modeparsers.RegisterKeyParser(win_id, KM.set_mark, modeman),
+ KM.jump_mark:
+ modeparsers.RegisterKeyParser(win_id, KM.jump_mark, modeman),
+ KM.record_macro:
+ modeparsers.RegisterKeyParser(win_id, KM.record_macro, modeman),
+ KM.run_macro:
+ modeparsers.RegisterKeyParser(win_id, KM.run_macro, modeman),
}
objreg.register('keyparsers', keyparsers, scope='window', window=win_id)
modeman.destroyed.connect(
@@ -149,11 +155,12 @@ class ModeManager(QObject):
def __repr__(self):
return utils.get_repr(self, mode=self.mode)
- def _eventFilter_keypress(self, event):
+ def _handle_keypress(self, event, *, dry_run=False):
"""Handle filtering of KeyPress events.
Args:
event: The KeyPress to examine.
+ dry_run: Don't actually handle the key, only filter it.
Return:
True if event should be filtered, False otherwise.
@@ -163,7 +170,7 @@ class ModeManager(QObject):
if curmode != usertypes.KeyMode.insert:
log.modes.debug("got keypress in mode {} - delegating to "
"{}".format(curmode, utils.qualname(parser)))
- handled = parser.handle(event)
+ match = parser.handle(event, dry_run=dry_run)
is_non_alnum = (
event.modifiers() not in [Qt.NoModifier, Qt.ShiftModifier] or
@@ -171,7 +178,7 @@ class ModeManager(QObject):
forward_unbound_keys = config.val.input.forward_unbound_keys
- if handled:
+ if match:
filter_this = True
elif (parser.passthrough or forward_unbound_keys == 'all' or
(forward_unbound_keys == 'auto' and is_non_alnum)):
@@ -179,20 +186,20 @@ class ModeManager(QObject):
else:
filter_this = True
- if not filter_this:
+ if not filter_this and not dry_run:
self._releaseevents_to_pass.add(KeyEvent.from_event(event))
if curmode != usertypes.KeyMode.insert:
focus_widget = QApplication.instance().focusWidget()
- log.modes.debug("handled: {}, forward_unbound_keys: {}, "
- "passthrough: {}, is_non_alnum: {} --> "
- "filter: {} (focused: {!r})".format(
- handled, forward_unbound_keys,
- parser.passthrough, is_non_alnum, filter_this,
- focus_widget))
+ log.modes.debug("match: {}, forward_unbound_keys: {}, "
+ "passthrough: {}, is_non_alnum: {}, dry_run: {} "
+ "--> filter: {} (focused: {!r})".format(
+ match, forward_unbound_keys,
+ parser.passthrough, is_non_alnum, dry_run,
+ filter_this, focus_widget))
return filter_this
- def _eventFilter_keyrelease(self, event):
+ def _handle_keyrelease(self, event):
"""Handle filtering of KeyRelease events.
Args:
@@ -315,7 +322,7 @@ class ModeManager(QObject):
raise ValueError("Can't leave normal mode!")
self.leave(self.mode, 'leave current')
- def eventFilter(self, event):
+ def handle_event(self, event):
"""Filter all events based on the currently set mode.
Also calls the real keypress handler.
@@ -331,8 +338,10 @@ class ModeManager(QObject):
return False
handlers = {
- QEvent.KeyPress: self._eventFilter_keypress,
- QEvent.KeyRelease: self._eventFilter_keyrelease,
+ QEvent.KeyPress: self._handle_keypress,
+ QEvent.KeyRelease: self._handle_keyrelease,
+ QEvent.ShortcutOverride:
+ functools.partial(self._handle_keypress, dry_run=True),
}
handler = handlers[event.type()]
return handler(event)
diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py
index b739d38a1..270590fff 100644
--- a/qutebrowser/keyinput/modeparsers.py
+++ b/qutebrowser/keyinput/modeparsers.py
@@ -27,10 +27,11 @@ import traceback
import enum
from PyQt5.QtCore import pyqtSlot, Qt
+from PyQt5.QtGui import QKeySequence
-from qutebrowser.commands import cmdexc
+from qutebrowser.commands import runners, cmdexc
from qutebrowser.config import config
-from qutebrowser.keyinput import keyparser
+from qutebrowser.keyinput import basekeyparser, keyutils
from qutebrowser.utils import usertypes, log, message, objreg, utils
@@ -38,7 +39,26 @@ STARTCHARS = ":/?"
LastPress = enum.Enum('LastPress', ['none', 'filtertext', 'keystring'])
-class NormalKeyParser(keyparser.CommandKeyParser):
+class CommandKeyParser(basekeyparser.BaseKeyParser):
+
+ """KeyChainParser for command bindings.
+
+ Attributes:
+ _commandrunner: CommandRunner instance.
+ """
+
+ def __init__(self, win_id, parent=None, supports_count=None):
+ super().__init__(win_id, parent, supports_count)
+ self._commandrunner = runners.CommandRunner(win_id)
+
+ def execute(self, cmdstr, count=None):
+ try:
+ self._commandrunner.run(cmdstr, count)
+ except cmdexc.Error as e:
+ message.error(str(e), stack=traceback.format_exc())
+
+
+class NormalKeyParser(CommandKeyParser):
"""KeyParser for normal mode with added STARTCHARS detection and more.
@@ -47,8 +67,7 @@ class NormalKeyParser(keyparser.CommandKeyParser):
"""
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=True,
- supports_chains=True)
+ super().__init__(win_id, parent, supports_count=True)
self._read_config('normal')
self._partial_timer = usertypes.Timer(self, 'partial-match')
self._partial_timer.setSingleShot(True)
@@ -59,11 +78,13 @@ class NormalKeyParser(keyparser.CommandKeyParser):
def __repr__(self):
return utils.get_repr(self)
- def _handle_single_key(self, e):
- """Override _handle_single_key to abort if the key is a startchar.
+ def handle(self, e, *, dry_run=False):
+ """Override to abort if the key is a startchar.
Args:
e: the KeyPressEvent from Qt.
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Return:
A self.Match member.
@@ -72,9 +93,11 @@ class NormalKeyParser(keyparser.CommandKeyParser):
if self._inhibited:
self._debug_log("Ignoring key '{}', because the normal mode is "
"currently inhibited.".format(txt))
- return self.Match.none
- match = super()._handle_single_key(e)
- if match == self.Match.partial:
+ return QKeySequence.NoMatch
+
+ match = super().handle(e, dry_run=dry_run)
+
+ if match == QKeySequence.PartialMatch and not dry_run:
timeout = config.val.input.partial_timeout
if timeout != 0:
self._partial_timer.setInterval(timeout)
@@ -96,9 +119,9 @@ class NormalKeyParser(keyparser.CommandKeyParser):
def _clear_partial_match(self):
"""Clear a partial keystring after a timeout."""
self._debug_log("Clearing partial keystring {}".format(
- self._keystring))
- self._keystring = ''
- self.keystring_updated.emit(self._keystring)
+ self._sequence))
+ self._sequence = keyutils.KeySequence()
+ self.keystring_updated.emit(str(self._sequence))
@pyqtSlot()
def _clear_inhibited(self):
@@ -123,22 +146,48 @@ class NormalKeyParser(keyparser.CommandKeyParser):
pass
-class PromptKeyParser(keyparser.CommandKeyParser):
+class PassthroughKeyParser(CommandKeyParser):
+
+ """KeyChainParser which passes through normal keys.
+
+ Used for insert/passthrough modes.
+
+ Attributes:
+ _mode: The mode this keyparser is for.
+ """
+
+ do_log = False
+ passthrough = True
+
+ def __init__(self, win_id, mode, parent=None):
+ """Constructor.
+
+ Args:
+ mode: The mode this keyparser is for.
+ parent: Qt parent.
+ warn: Whether to warn if an ignored key was bound.
+ """
+ super().__init__(win_id, parent)
+ self._read_config(mode)
+ self._mode = mode
+
+ def __repr__(self):
+ return utils.get_repr(self, mode=self._mode)
+
+
+class PromptKeyParser(CommandKeyParser):
"""KeyParser for yes/no prompts."""
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=False,
- supports_chains=True)
- # We don't want an extra section for this in the config, so we just
- # abuse the prompt section.
- self._read_config('prompt')
+ super().__init__(win_id, parent, supports_count=False)
+ self._read_config('yesno')
def __repr__(self):
return utils.get_repr(self)
-class HintKeyParser(keyparser.CommandKeyParser):
+class HintKeyParser(CommandKeyParser):
"""KeyChainParser for hints.
@@ -148,15 +197,14 @@ class HintKeyParser(keyparser.CommandKeyParser):
"""
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=False,
- supports_chains=True)
+ super().__init__(win_id, parent, supports_count=False)
self._filtertext = ''
self._last_press = LastPress.none
self._read_config('hint')
self.keystring_updated.connect(self.on_keystring_updated)
- def _handle_special_key(self, e):
- """Override _handle_special_key to handle string filtering.
+ def _handle_filter_key(self, e):
+ """Handle keys for string filtering.
Return True if the keypress has been handled, and False if not.
@@ -164,78 +212,75 @@ class HintKeyParser(keyparser.CommandKeyParser):
e: the KeyPressEvent from Qt.
Return:
- True if event has been handled, False otherwise.
+ A QKeySequence match.
"""
- log.keyboard.debug("Got special key 0x{:x} text {}".format(
+ log.keyboard.debug("Got filter key 0x{:x} text {}".format(
e.key(), e.text()))
hintmanager = objreg.get('hintmanager', scope='tab',
window=self._win_id, tab='current')
if e.key() == Qt.Key_Backspace:
log.keyboard.debug("Got backspace, mode {}, filtertext '{}', "
- "keystring '{}'".format(self._last_press,
- self._filtertext,
- self._keystring))
+ "sequence '{}'".format(self._last_press,
+ self._filtertext,
+ self._sequence))
if self._last_press == LastPress.filtertext and self._filtertext:
self._filtertext = self._filtertext[:-1]
hintmanager.filter_hints(self._filtertext)
- return True
- elif self._last_press == LastPress.keystring and self._keystring:
- self._keystring = self._keystring[:-1]
- self.keystring_updated.emit(self._keystring)
- if not self._keystring and self._filtertext:
+ return QKeySequence.ExactMatch
+ elif self._last_press == LastPress.keystring and self._sequence:
+ self._sequence = self._sequence[:-1]
+ self.keystring_updated.emit(str(self._sequence))
+ if not self._sequence and self._filtertext:
# Switch back to hint filtering mode (this can happen only
# in numeric mode after the number has been deleted).
hintmanager.filter_hints(self._filtertext)
self._last_press = LastPress.filtertext
- return True
+ return QKeySequence.ExactMatch
else:
- return super()._handle_special_key(e)
+ return QKeySequence.NoMatch
elif hintmanager.current_mode() != 'number':
- return super()._handle_special_key(e)
+ return QKeySequence.NoMatch
elif not e.text():
- return super()._handle_special_key(e)
+ return QKeySequence.NoMatch
else:
self._filtertext += e.text()
hintmanager.filter_hints(self._filtertext)
self._last_press = LastPress.filtertext
- return True
+ return QKeySequence.ExactMatch
- def handle(self, e):
+ def handle(self, e, *, dry_run=False):
"""Handle a new keypress and call the respective handlers.
Args:
e: the KeyPressEvent from Qt
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Returns:
True if the match has been handled, False otherwise.
"""
- match = self._handle_single_key(e)
- if match == self.Match.partial:
- self.keystring_updated.emit(self._keystring)
+ dry_run_match = super().handle(e, dry_run=True)
+ if dry_run:
+ return dry_run_match
+
+ if keyutils.is_special(e.key(), e.modifiers()):
+ log.keyboard.debug("Got special key, clearing keychain")
+ self.clear_keystring()
+
+ assert not dry_run
+ match = super().handle(e)
+
+ if match == QKeySequence.PartialMatch:
self._last_press = LastPress.keystring
- return True
- elif match == self.Match.definitive:
+ elif match == QKeySequence.ExactMatch:
self._last_press = LastPress.none
- return True
- elif match == self.Match.other:
- return None
- elif match == self.Match.none:
+ elif match == QKeySequence.NoMatch:
# We couldn't find a keychain so we check if it's a special key.
- return self._handle_special_key(e)
+ return self._handle_filter_key(e)
else:
raise ValueError("Got invalid match type {}!".format(match))
- def execute(self, cmdstr, keytype, count=None):
- """Handle a completed keychain."""
- if not isinstance(keytype, self.Type):
- raise TypeError("Type {} is no Type member!".format(keytype))
- if keytype == self.Type.chain:
- hintmanager = objreg.get('hintmanager', scope='tab',
- window=self._win_id, tab='current')
- hintmanager.handle_partial_key(cmdstr)
- else:
- # execute as command
- super().execute(cmdstr, keytype, count)
+ return match
def update_bindings(self, strings, preserve_filter=False):
"""Update bindings when the hint strings changed.
@@ -245,7 +290,9 @@ class HintKeyParser(keyparser.CommandKeyParser):
preserve_filter: Whether to keep the current value of
`self._filtertext`.
"""
- self.bindings = {s: s for s in strings}
+ self._read_config()
+ self.bindings.update({keyutils.KeySequence.parse(s):
+ 'follow-hint -s ' + s for s in strings})
if not preserve_filter:
self._filtertext = ''
@@ -257,19 +304,18 @@ class HintKeyParser(keyparser.CommandKeyParser):
hintmanager.handle_partial_key(keystr)
-class CaretKeyParser(keyparser.CommandKeyParser):
+class CaretKeyParser(CommandKeyParser):
"""KeyParser for caret mode."""
passthrough = True
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=True,
- supports_chains=True)
+ super().__init__(win_id, parent, supports_count=True)
self._read_config('caret')
-class RegisterKeyParser(keyparser.CommandKeyParser):
+class RegisterKeyParser(CommandKeyParser):
"""KeyParser for modes that record a register key.
@@ -279,28 +325,30 @@ class RegisterKeyParser(keyparser.CommandKeyParser):
"""
def __init__(self, win_id, mode, parent=None):
- super().__init__(win_id, parent, supports_count=False,
- supports_chains=False)
+ super().__init__(win_id, parent, supports_count=False)
self._mode = mode
self._read_config('register')
- def handle(self, e):
+ def handle(self, e, *, dry_run=False):
"""Override handle to always match the next key and use the register.
Args:
e: the KeyPressEvent from Qt.
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Return:
True if event has been handled, False otherwise.
"""
- if super().handle(e):
- return True
+ match = super().handle(e, dry_run=dry_run)
+ if match or dry_run:
+ return match
- key = e.text()
-
- if key == '' or utils.keyevent_to_string(e) is None:
+ if keyutils.is_special(e.key(), e.modifiers()):
# this is not a proper register key, let it pass and keep going
- return False
+ return QKeySequence.NoMatch
+
+ key = e.text()
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
@@ -322,5 +370,4 @@ class RegisterKeyParser(keyparser.CommandKeyParser):
message.error(str(err), stack=traceback.format_exc())
self.request_leave.emit(self._mode, "valid register key", True)
-
- return True
+ return QKeySequence.ExactMatch
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
index 18b349d5d..482291253 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -327,7 +327,7 @@ class MainWindow(QWidget):
self.tabbed_browser)
objreg.register('command-dispatcher', dispatcher, scope='window',
window=self.win_id)
- self.tabbed_browser.destroyed.connect(
+ self.tabbed_browser.widget.destroyed.connect(
functools.partial(objreg.delete, 'command-dispatcher',
scope='window', window=self.win_id))
@@ -347,10 +347,10 @@ class MainWindow(QWidget):
def _add_widgets(self):
"""Add or readd all widgets to the VBox."""
- self._vbox.removeWidget(self.tabbed_browser)
+ self._vbox.removeWidget(self.tabbed_browser.widget)
self._vbox.removeWidget(self._downloadview)
self._vbox.removeWidget(self.status)
- widgets = [self.tabbed_browser]
+ widgets = [self.tabbed_browser.widget]
downloads_position = config.val.downloads.position
if downloads_position == 'top':
@@ -469,7 +469,7 @@ class MainWindow(QWidget):
self.tabbed_browser.cur_scroll_perc_changed.connect(
status.percentage.set_perc)
- self.tabbed_browser.tab_index_changed.connect(
+ self.tabbed_browser.widget.tab_index_changed.connect(
status.tabindex.on_tab_index_changed)
self.tabbed_browser.cur_url_changed.connect(status.url.set_url)
@@ -518,7 +518,7 @@ class MainWindow(QWidget):
super().resizeEvent(e)
self._update_overlay_geometries()
self._downloadview.updateGeometry()
- self.tabbed_browser.tabBar().refresh()
+ self.tabbed_browser.widget.tabBar().refresh()
def showEvent(self, e):
"""Extend showEvent to register us as the last-visible-main-window.
@@ -547,7 +547,7 @@ class MainWindow(QWidget):
if crashsignal.is_crashing:
e.accept()
return
- tab_count = self.tabbed_browser.count()
+ tab_count = self.tabbed_browser.widget.count()
download_model = objreg.get('download-model', scope='window',
window=self.win_id)
download_count = download_model.running_downloads()
diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py
index 931d32654..f7af28440 100644
--- a/qutebrowser/mainwindow/prompt.py
+++ b/qutebrowser/mainwindow/prompt.py
@@ -507,8 +507,8 @@ class _BasePrompt(QWidget):
self._key_grid = QGridLayout()
self._key_grid.setVerticalSpacing(0)
- # The bindings are all in the 'prompt' mode, even for yesno prompts
- all_bindings = config.key_instance.get_reverse_bindings_for('prompt')
+ all_bindings = config.key_instance.get_reverse_bindings_for(
+ self.KEY_MODE.name)
labels = []
for cmd, text in self._allowed_commands():
@@ -596,6 +596,8 @@ class FilenamePrompt(_BasePrompt):
if config.val.prompt.filebrowser:
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
+ self._to_complete = ''
+
@pyqtSlot(str)
def _set_fileview_root(self, path, *, tabbed=False):
"""Set the root path for the file display."""
@@ -604,6 +606,9 @@ class FilenamePrompt(_BasePrompt):
separators += os.altsep
dirname = os.path.dirname(path)
+ basename = os.path.basename(path)
+ if not tabbed:
+ self._to_complete = ''
try:
if not path:
@@ -617,6 +622,7 @@ class FilenamePrompt(_BasePrompt):
elif os.path.isdir(dirname) and not tabbed:
# Input like /foo/ba -> show /foo contents
path = dirname
+ self._to_complete = basename
else:
return
except OSError:
@@ -634,7 +640,11 @@ class FilenamePrompt(_BasePrompt):
index: The QModelIndex of the selected element.
clicked: Whether the element was clicked.
"""
- path = os.path.normpath(self._file_model.filePath(index))
+ if index == QModelIndex():
+ path = os.path.join(self._file_model.rootPath(), self._to_complete)
+ else:
+ path = os.path.normpath(self._file_model.filePath(index))
+
if clicked:
path += os.sep
else:
@@ -696,6 +706,7 @@ class FilenamePrompt(_BasePrompt):
assert last_index.isValid()
idx = selmodel.currentIndex()
+
if not idx.isValid():
# No item selected yet
idx = last_index if which == 'prev' else first_index
@@ -709,10 +720,24 @@ class FilenamePrompt(_BasePrompt):
if not idx.isValid():
idx = last_index if which == 'prev' else first_index
+ idx = self._do_completion(idx, which)
+
selmodel.setCurrentIndex(
idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
self._insert_path(idx, clicked=False)
+ def _do_completion(self, idx, which):
+ filename = self._file_model.fileName(idx)
+ while not filename.startswith(self._to_complete) and idx.isValid():
+ if which == 'prev':
+ idx = self._file_view.indexAbove(idx)
+ else:
+ assert which == 'next', which
+ idx = self._file_view.indexBelow(idx)
+ filename = self._file_model.fileName(idx)
+
+ return idx
+
def _allowed_commands(self):
return [('prompt-accept', 'Accept'), ('leave-mode', 'Abort')]
diff --git a/qutebrowser/mainwindow/statusbar/backforward.py b/qutebrowser/mainwindow/statusbar/backforward.py
index 8ea60ee75..5e244cf8c 100644
--- a/qutebrowser/mainwindow/statusbar/backforward.py
+++ b/qutebrowser/mainwindow/statusbar/backforward.py
@@ -32,7 +32,7 @@ class Backforward(textbase.TextBase):
def on_tab_cur_url_changed(self, tabs):
"""Called on URL changes."""
- tab = tabs.currentWidget()
+ tab = tabs.widget.currentWidget()
if tab is None: # pragma: no cover
self.setText('')
self.hide()
diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py
index 8057bfdb8..9efc26858 100644
--- a/qutebrowser/mainwindow/statusbar/bar.py
+++ b/qutebrowser/mainwindow/statusbar/bar.py
@@ -268,7 +268,7 @@ class StatusBar(QWidget):
"""Get the currently displayed tab."""
window = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
- return window.currentWidget()
+ return window.widget.currentWidget()
def set_mode_active(self, mode, val):
"""Setter for self.{insert,command,caret}_active.
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index df5ab5584..1c757ad18 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -22,7 +22,7 @@
import functools
import attr
-from PyQt5.QtWidgets import QSizePolicy
+from PyQt5.QtWidgets import QSizePolicy, QWidget
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl
from PyQt5.QtGui import QIcon
@@ -50,7 +50,7 @@ class TabDeletedError(Exception):
"""Exception raised when _tab_index is called for a deleted tab."""
-class TabbedBrowser(tabwidget.TabWidget):
+class TabbedBrowser(QWidget):
"""A TabWidget with QWebViews inside.
@@ -110,17 +110,18 @@ class TabbedBrowser(tabwidget.TabWidget):
new_tab = pyqtSignal(browsertab.AbstractTab, int)
def __init__(self, *, win_id, private, parent=None):
- super().__init__(win_id, parent)
+ super().__init__(parent)
+ self.widget = tabwidget.TabWidget(win_id, parent=self)
self._win_id = win_id
self._tab_insert_idx_left = 0
self._tab_insert_idx_right = -1
self.shutting_down = False
- self.tabCloseRequested.connect(self.on_tab_close_requested)
- self.new_tab_requested.connect(self.tabopen)
- self.currentChanged.connect(self.on_current_changed)
+ self.widget.tabCloseRequested.connect(self.on_tab_close_requested)
+ self.widget.new_tab_requested.connect(self.tabopen)
+ self.widget.currentChanged.connect(self.on_current_changed)
self.cur_load_started.connect(self.on_cur_load_started)
- self.cur_fullscreen_requested.connect(self.tabBar().maybe_hide)
- self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide)
+ self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._undo_stack = []
self._filter = signalfilter.SignalFilter(win_id, self)
self._now_focused = None
@@ -128,12 +129,12 @@ class TabbedBrowser(tabwidget.TabWidget):
self.search_options = {}
self._local_marks = {}
self._global_marks = {}
- self.default_window_icon = self.window().windowIcon()
+ self.default_window_icon = self.widget.window().windowIcon()
self.private = private
config.instance.changed.connect(self._on_config_changed)
def __repr__(self):
- return utils.get_repr(self, count=self.count())
+ return utils.get_repr(self, count=self.widget.count())
@pyqtSlot(str)
def _on_config_changed(self, option):
@@ -142,7 +143,7 @@ class TabbedBrowser(tabwidget.TabWidget):
elif option == 'window.title_format':
self._update_window_title()
elif option in ['tabs.title.format', 'tabs.title.format_pinned']:
- self._update_tab_titles()
+ self.widget.update_tab_titles()
def _tab_index(self, tab):
"""Get the index of a given tab.
@@ -150,7 +151,7 @@ class TabbedBrowser(tabwidget.TabWidget):
Raises TabDeletedError if the tab doesn't exist anymore.
"""
try:
- idx = self.indexOf(tab)
+ idx = self.widget.indexOf(tab)
except RuntimeError as e:
log.webview.debug("Got invalid tab ({})!".format(e))
raise TabDeletedError(e)
@@ -166,8 +167,8 @@ class TabbedBrowser(tabwidget.TabWidget):
iterating over the list.
"""
widgets = []
- for i in range(self.count()):
- widget = self.widget(i)
+ for i in range(self.widget.count()):
+ widget = self.widget.widget(i)
if widget is None:
log.webview.debug("Got None-widget in tabbedbrowser!")
else:
@@ -186,16 +187,16 @@ class TabbedBrowser(tabwidget.TabWidget):
if field is not None and ('{' + field + '}') not in title_format:
return
- idx = self.currentIndex()
+ idx = self.widget.currentIndex()
if idx == -1:
# (e.g. last tab removed)
log.webview.debug("Not updating window title because index is -1")
return
- fields = self.get_tab_fields(idx)
+ fields = self.widget.get_tab_fields(idx)
fields['id'] = self._win_id
title = title_format.format(**fields)
- self.window().setWindowTitle(title)
+ self.widget.window().setWindowTitle(title)
def _connect_tab_signals(self, tab):
"""Set up the needed signals for tab."""
@@ -247,8 +248,8 @@ class TabbedBrowser(tabwidget.TabWidget):
Return:
The current URL as QUrl.
"""
- idx = self.currentIndex()
- return super().tab_url(idx)
+ idx = self.widget.currentIndex()
+ return self.widget.tab_url(idx)
def shutdown(self):
"""Try to shut down all tabs cleanly."""
@@ -284,7 +285,7 @@ class TabbedBrowser(tabwidget.TabWidget):
new_undo: Whether the undo entry should be a new item in the stack.
"""
last_close = config.val.tabs.last_close
- count = self.count()
+ count = self.widget.count()
if last_close == 'ignore' and count == 1:
return
@@ -311,7 +312,7 @@ class TabbedBrowser(tabwidget.TabWidget):
new_undo: Whether the undo entry should be a new item in the stack.
crashed: Whether we're closing a tab with crashed renderer process.
"""
- idx = self.indexOf(tab)
+ idx = self.widget.indexOf(tab)
if idx == -1:
if crashed:
return
@@ -349,7 +350,7 @@ class TabbedBrowser(tabwidget.TabWidget):
self._undo_stack[-1].append(entry)
tab.shutdown()
- self.removeTab(idx)
+ self.widget.removeTab(idx)
if not crashed:
# WORKAROUND for a segfault when we delete the crashed tab.
# see https://bugreports.qt.io/browse/QTBUG-58698
@@ -362,14 +363,14 @@ class TabbedBrowser(tabwidget.TabWidget):
last_close = config.val.tabs.last_close
use_current_tab = False
if last_close in ['blank', 'startpage', 'default-page']:
- only_one_tab_open = self.count() == 1
- no_history = len(self.widget(0).history) == 1
+ only_one_tab_open = self.widget.count() == 1
+ no_history = len(self.widget.widget(0).history) == 1
urls = {
'blank': QUrl('about:blank'),
'startpage': config.val.url.start_pages[0],
'default-page': config.val.url.default_page,
}
- first_tab_url = self.widget(0).url()
+ first_tab_url = self.widget.widget(0).url()
last_close_urlstr = urls[last_close].toString().rstrip('/')
first_tab_urlstr = first_tab_url.toString().rstrip('/')
last_close_url_used = first_tab_urlstr == last_close_urlstr
@@ -378,15 +379,13 @@ class TabbedBrowser(tabwidget.TabWidget):
for entry in reversed(self._undo_stack.pop()):
if use_current_tab:
- self.openurl(entry.url, newtab=False)
- newtab = self.widget(0)
+ newtab = self.widget.widget(0)
use_current_tab = False
else:
- newtab = self.tabopen(entry.url, background=False,
- idx=entry.index)
+ newtab = self.tabopen(background=False, idx=entry.index)
newtab.history.deserialize(entry.history)
- self.set_tab_pinned(newtab, entry.pinned)
+ self.widget.set_tab_pinned(newtab, entry.pinned)
@pyqtSlot('QUrl', bool)
def openurl(self, url, newtab):
@@ -397,15 +396,15 @@ class TabbedBrowser(tabwidget.TabWidget):
newtab: True to open URL in a new tab, False otherwise.
"""
qtutils.ensure_valid(url)
- if newtab or self.currentWidget() is None:
+ if newtab or self.widget.currentWidget() is None:
self.tabopen(url, background=False)
else:
- self.currentWidget().openurl(url)
+ self.widget.currentWidget().openurl(url)
@pyqtSlot(int)
def on_tab_close_requested(self, idx):
"""Close a tab via an index."""
- tab = self.widget(idx)
+ tab = self.widget.widget(idx)
if tab is None:
log.webview.debug("Got invalid tab {} for index {}!".format(
tab, idx))
@@ -456,7 +455,7 @@ class TabbedBrowser(tabwidget.TabWidget):
"related {}, idx {}".format(
url, background, related, idx))
- if (config.val.tabs.tabs_are_windows and self.count() > 0 and
+ if (config.val.tabs.tabs_are_windows and self.widget.count() > 0 and
not ignore_tabs_are_windows):
window = mainwindow.MainWindow(private=self.private)
window.show()
@@ -466,12 +465,12 @@ class TabbedBrowser(tabwidget.TabWidget):
related=related)
tab = browsertab.create(win_id=self._win_id, private=self.private,
- parent=self)
+ parent=self.widget)
self._connect_tab_signals(tab)
if idx is None:
idx = self._get_new_tab_idx(related)
- self.insertTab(idx, tab, "")
+ self.widget.insertTab(idx, tab, "")
if url is not None:
tab.openurl(url)
@@ -482,10 +481,11 @@ class TabbedBrowser(tabwidget.TabWidget):
# Make sure the background tab has the correct initial size.
# With a foreground tab, it's going to be resized correctly by the
# layout anyways.
- tab.resize(self.currentWidget().size())
- self.tab_index_changed.emit(self.currentIndex(), self.count())
+ tab.resize(self.widget.currentWidget().size())
+ self.widget.tab_index_changed.emit(self.widget.currentIndex(),
+ self.widget.count())
else:
- self.setCurrentWidget(tab)
+ self.widget.setCurrentWidget(tab)
tab.show()
self.new_tab.emit(tab, idx)
@@ -530,13 +530,14 @@ class TabbedBrowser(tabwidget.TabWidget):
"""Update favicons when config was changed."""
for i, tab in enumerate(self.widgets()):
if config.val.tabs.favicons.show:
- self.setTabIcon(i, tab.icon())
+ self.widget.setTabIcon(i, tab.icon())
if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(tab.icon())
+ self.widget.window().setWindowIcon(tab.icon())
else:
- self.setTabIcon(i, QIcon())
+ self.widget.setTabIcon(i, QIcon())
if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(self.default_window_icon)
+ window = self.widget.window()
+ window.setWindowIcon(self.default_window_icon)
@pyqtSlot()
def on_load_started(self, tab):
@@ -550,15 +551,14 @@ class TabbedBrowser(tabwidget.TabWidget):
except TabDeletedError:
# We can get signals for tabs we already deleted...
return
- self._update_tab_title(idx)
+ self.widget.update_tab_title(idx)
if tab.data.keep_icon:
tab.data.keep_icon = False
else:
- self.setTabIcon(idx, QIcon())
if (config.val.tabs.tabs_are_windows and
config.val.tabs.favicons.show):
- self.window().setWindowIcon(self.default_window_icon)
- if idx == self.currentIndex():
+ self.widget.window().setWindowIcon(self.default_window_icon)
+ if idx == self.widget.currentIndex():
self._update_window_title()
@pyqtSlot()
@@ -589,8 +589,8 @@ class TabbedBrowser(tabwidget.TabWidget):
return
log.webview.debug("Changing title for idx {} to '{}'".format(
idx, text))
- self.set_page_title(idx, text)
- if idx == self.currentIndex():
+ self.widget.set_page_title(idx, text)
+ if idx == self.widget.currentIndex():
self._update_window_title()
@pyqtSlot(browsertab.AbstractTab, QUrl)
@@ -607,8 +607,8 @@ class TabbedBrowser(tabwidget.TabWidget):
# We can get signals for tabs we already deleted...
return
- if not self.page_title(idx):
- self.set_page_title(idx, url.toDisplayString())
+ if not self.widget.page_title(idx):
+ self.widget.set_page_title(idx, url.toDisplayString())
@pyqtSlot(browsertab.AbstractTab, QIcon)
def on_icon_changed(self, tab, icon):
@@ -627,23 +627,23 @@ class TabbedBrowser(tabwidget.TabWidget):
except TabDeletedError:
# We can get signals for tabs we already deleted...
return
- self.setTabIcon(idx, icon)
+ self.widget.setTabIcon(idx, icon)
if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(icon)
+ self.widget.window().setWindowIcon(icon)
@pyqtSlot(usertypes.KeyMode)
def on_mode_entered(self, mode):
"""Save input mode when tabs.mode_on_change = restore."""
if (config.val.tabs.mode_on_change == 'restore' and
mode in modeman.INPUT_MODES):
- tab = self.currentWidget()
+ tab = self.widget.currentWidget()
if tab is not None:
tab.data.input_mode = mode
@pyqtSlot(usertypes.KeyMode)
def on_mode_left(self, mode):
"""Give focus to current tab if command mode was left."""
- widget = self.currentWidget()
+ widget = self.widget.currentWidget()
if widget is None:
return
if mode in [usertypes.KeyMode.command] + modeman.PROMPT_MODES:
@@ -660,7 +660,7 @@ class TabbedBrowser(tabwidget.TabWidget):
if idx == -1 or self.shutting_down:
# closing the last tab (before quitting) or shutting down
return
- tab = self.widget(idx)
+ tab = self.widget.widget(idx)
if tab is None:
log.webview.debug("on_current_changed got called with invalid "
"index {}".format(idx))
@@ -690,8 +690,8 @@ class TabbedBrowser(tabwidget.TabWidget):
self._now_focused = tab
self.current_tab_changed.emit(tab)
QTimer.singleShot(0, self._update_window_title)
- self._tab_insert_idx_left = self.currentIndex()
- self._tab_insert_idx_right = self.currentIndex() + 1
+ self._tab_insert_idx_left = self.widget.currentIndex()
+ self._tab_insert_idx_right = self.widget.currentIndex() + 1
@pyqtSlot()
def on_cmd_return_pressed(self):
@@ -709,9 +709,9 @@ class TabbedBrowser(tabwidget.TabWidget):
stop = config.val.colors.tabs.indicator.stop
system = config.val.colors.tabs.indicator.system
color = utils.interpolate_color(start, stop, perc, system)
- self.set_tab_indicator_color(idx, color)
- self._update_tab_title(idx)
- if idx == self.currentIndex():
+ self.widget.set_tab_indicator_color(idx, color)
+ self.widget.update_tab_title(idx)
+ if idx == self.widget.currentIndex():
self._update_window_title()
def on_load_finished(self, tab, ok):
@@ -728,23 +728,23 @@ class TabbedBrowser(tabwidget.TabWidget):
color = utils.interpolate_color(start, stop, 100, system)
else:
color = config.val.colors.tabs.indicator.error
- self.set_tab_indicator_color(idx, color)
- self._update_tab_title(idx)
- if idx == self.currentIndex():
+ self.widget.set_tab_indicator_color(idx, color)
+ self.widget.update_tab_title(idx)
+ if idx == self.widget.currentIndex():
self._update_window_title()
tab.handle_auto_insert_mode(ok)
@pyqtSlot()
def on_scroll_pos_changed(self):
"""Update tab and window title when scroll position changed."""
- idx = self.currentIndex()
+ idx = self.widget.currentIndex()
if idx == -1:
# (e.g. last tab removed)
log.webview.debug("Not updating scroll position because index is "
"-1")
return
self._update_window_title('scroll_pos')
- self._update_tab_title(idx, 'scroll_pos')
+ self.widget.update_tab_title(idx, 'scroll_pos')
def _on_renderer_process_terminated(self, tab, status, code):
"""Show an error when a renderer process terminated."""
@@ -777,7 +777,7 @@ class TabbedBrowser(tabwidget.TabWidget):
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698
message.error(msg)
self._remove_tab(tab, crashed=True)
- if self.count() == 0:
+ if self.widget.count() == 0:
self.tabopen(QUrl('about:blank'))
def resizeEvent(self, e):
@@ -814,7 +814,7 @@ class TabbedBrowser(tabwidget.TabWidget):
if key != "'":
message.error("Failed to set mark: url invalid")
return
- point = self.currentWidget().scroller.pos_px()
+ point = self.widget.currentWidget().scroller.pos_px()
if key.isupper():
self._global_marks[key] = point, url
@@ -835,7 +835,7 @@ class TabbedBrowser(tabwidget.TabWidget):
except qtutils.QtValueError:
urlkey = None
- tab = self.currentWidget()
+ tab = self.widget.currentWidget()
if key.isupper():
if key in self._global_marks:
diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py
index 965e5b219..abc6cedae 100644
--- a/qutebrowser/mainwindow/tabwidget.py
+++ b/qutebrowser/mainwindow/tabwidget.py
@@ -60,7 +60,7 @@ class TabWidget(QTabWidget):
self.setTabBar(bar)
bar.tabCloseRequested.connect(self.tabCloseRequested)
bar.tabMoved.connect(functools.partial(
- QTimer.singleShot, 0, self._update_tab_titles))
+ QTimer.singleShot, 0, self.update_tab_titles))
bar.currentChanged.connect(self._on_current_changed)
bar.new_tab_requested.connect(self._on_new_tab_requested)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
@@ -108,7 +108,7 @@ class TabWidget(QTabWidget):
bar.set_tab_data(idx, 'pinned', pinned)
tab.data.pinned = pinned
- self._update_tab_title(idx)
+ self.update_tab_title(idx)
def tab_indicator_color(self, idx):
"""Get the tab indicator color for the given index."""
@@ -117,13 +117,13 @@ class TabWidget(QTabWidget):
def set_page_title(self, idx, title):
"""Set the tab title user data."""
self.tabBar().set_tab_data(idx, 'page-title', title)
- self._update_tab_title(idx)
+ self.update_tab_title(idx)
def page_title(self, idx):
"""Get the tab title user data."""
return self.tabBar().page_title(idx)
- def _update_tab_title(self, idx, field=None):
+ def update_tab_title(self, idx, field=None):
"""Update the tab text for the given tab.
Args:
@@ -197,20 +197,20 @@ class TabWidget(QTabWidget):
fields['scroll_pos'] = scroll_pos
return fields
- def _update_tab_titles(self):
+ def update_tab_titles(self):
"""Update all texts."""
for idx in range(self.count()):
- self._update_tab_title(idx)
+ self.update_tab_title(idx)
def tabInserted(self, idx):
"""Update titles when a tab was inserted."""
super().tabInserted(idx)
- self._update_tab_titles()
+ self.update_tab_titles()
def tabRemoved(self, idx):
"""Update titles when a tab was removed."""
super().tabRemoved(idx)
- self._update_tab_titles()
+ self.update_tab_titles()
def addTab(self, page, icon_or_text, text_or_empty=None):
"""Override addTab to use our own text setting logic.
diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py
index c78d0848d..9649d27cc 100644
--- a/qutebrowser/misc/earlyinit.py
+++ b/qutebrowser/misc/earlyinit.py
@@ -172,6 +172,7 @@ def check_qt_version():
from PyQt5.QtCore import (qVersion, QT_VERSION, PYQT_VERSION,
PYQT_VERSION_STR)
from pkg_resources import parse_version
+ from qutebrowser.utils import log
if (QT_VERSION < 0x050701 or PYQT_VERSION < 0x050700 or
parse_version(qVersion()) < parse_version('5.7.1')):
text = ("Fatal error: Qt >= 5.7.1 and PyQt >= 5.7 are required, "
@@ -179,6 +180,10 @@ def check_qt_version():
PYQT_VERSION_STR))
_die(text)
+ if qVersion().startswith('5.8.'):
+ log.init.warning("Running qutebrowser with Qt 5.8 is untested and "
+ "unsupported!")
+
def check_ssl_support():
"""Check if SSL support is available."""
diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py
index 154660001..473f67c3e 100644
--- a/qutebrowser/misc/editor.py
+++ b/qutebrowser/misc/editor.py
@@ -42,6 +42,7 @@ class ExternalEditor(QObject):
_proc: The GUIProcess of the editor.
_watcher: A QFileSystemWatcher to watch the edited file for changes.
Only set if watch=True.
+ _content: The last-saved text of the editor.
Signals:
file_updated: The text in the edited file was updated.
@@ -112,19 +113,7 @@ class ExternalEditor(QObject):
if self._filename is not None:
raise ValueError("Already editing a file!")
try:
- # Close while the external process is running, as otherwise systems
- # with exclusive write access (e.g. Windows) may fail to update
- # the file from the external editor, see
- # https://github.com/qutebrowser/qutebrowser/issues/1767
- with tempfile.NamedTemporaryFile(
- # pylint: disable=bad-continuation
- mode='w', prefix='qutebrowser-editor-',
- encoding=config.val.editor.encoding,
- delete=False) as fobj:
- # pylint: enable=bad-continuation
- if text:
- fobj.write(text)
- self._filename = fobj.name
+ self._filename = self._create_tempfile(text, 'qutebrowser-editor-')
except OSError as e:
message.error("Failed to create initial file: {}".format(e))
return
@@ -134,6 +123,32 @@ class ExternalEditor(QObject):
line, column = self._calc_line_and_column(text, caret_position)
self._start_editor(line=line, column=column)
+ def backup(self):
+ """Create a backup if the content has changed from the original."""
+ if not self._content:
+ return
+ try:
+ fname = self._create_tempfile(self._content,
+ 'qutebrowser-editor-backup-')
+ message.info('Editor backup at {}'.format(fname))
+ except OSError as e:
+ message.error('Failed to create editor backup: {}'.format(e))
+
+ def _create_tempfile(self, text, prefix):
+ # Close while the external process is running, as otherwise systems
+ # with exclusive write access (e.g. Windows) may fail to update
+ # the file from the external editor, see
+ # https://github.com/qutebrowser/qutebrowser/issues/1767
+ with tempfile.NamedTemporaryFile(
+ # pylint: disable=bad-continuation
+ mode='w', prefix=prefix,
+ encoding=config.val.editor.encoding,
+ delete=False) as fobj:
+ # pylint: enable=bad-continuation
+ if text:
+ fobj.write(text)
+ return fobj.name
+
@pyqtSlot(str)
def _on_file_changed(self, path):
try:
diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py
index fad58da2d..11446aa40 100644
--- a/qutebrowser/misc/keyhintwidget.py
+++ b/qutebrowser/misc/keyhintwidget.py
@@ -34,6 +34,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt
from qutebrowser.config import config
from qutebrowser.utils import utils, usertypes
from qutebrowser.commands import cmdutils
+from qutebrowser.keyinput import keyutils
class KeyHintView(QLabel):
@@ -105,9 +106,8 @@ class KeyHintView(QLabel):
bindings_dict = config.key_instance.get_bindings_for(modename)
bindings = [(k, v) for (k, v) in sorted(bindings_dict.items())
- if k.startswith(prefix) and
- not utils.is_special_key(k) and
- not blacklisted(k) and
+ if keyutils.KeySequence.parse(prefix).matches(k) and
+ not blacklisted(str(k)) and
(takes_count(v) or not countstr)]
if not bindings:
@@ -120,7 +120,7 @@ class KeyHintView(QLabel):
suffix_color = html.escape(config.val.colors.keyhint.suffix.fg)
text = ''
- for key, cmd in bindings:
+ for seq, cmd in bindings:
text += (
"<tr>"
"<td>{}</td>"
@@ -130,7 +130,7 @@ class KeyHintView(QLabel):
).format(
html.escape(prefix),
suffix_color,
- html.escape(key[len(prefix):]),
+ html.escape(str(seq[len(prefix):])),
html.escape(cmd)
)
text = '<table>{}</table>'.format(text)
diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py
index 2e868e27c..ffdfc951a 100644
--- a/qutebrowser/misc/miscwidgets.py
+++ b/qutebrowser/misc/miscwidgets.py
@@ -25,8 +25,8 @@ from PyQt5.QtWidgets import (QLineEdit, QWidget, QHBoxLayout, QLabel,
from PyQt5.QtGui import QValidator, QPainter
from qutebrowser.config import config
-from qutebrowser.utils import utils, qtutils, log, usertypes
-from qutebrowser.misc import cmdhistory, objects
+from qutebrowser.utils import utils
+from qutebrowser.misc import cmdhistory
class MinimalLineEditMixin:
@@ -260,16 +260,6 @@ class WrapperLayout(QLayout):
self._widget = widget
container.setFocusProxy(widget)
widget.setParent(container)
- if (qtutils.version_check('5.8.0', exact=True, compiled=False) and
- objects.backend == usertypes.Backend.QtWebEngine and
- container.window() and
- container.window().windowHandle() and
- not container.window().windowHandle().isActive()):
- log.misc.debug("Calling QApplication::sync...")
- # WORKAROUND for:
- # https://bugreports.qt.io/browse/QTBUG-56652
- # https://codereview.qt-project.org/#/c/176113/5//ALL,unified
- QApplication.sync()
def unwrap(self):
self._widget.setParent(None)
@@ -293,8 +283,6 @@ class FullscreenNotification(QLabel):
bindings = all_bindings.get('fullscreen --leave')
if bindings:
key = bindings[0]
- if utils.is_special_key(key):
- key = key.strip('<>').capitalize()
self.setText("Press {} to exit fullscreen.".format(key))
else:
self.setText("Page is now fullscreen.")
diff --git a/qutebrowser/misc/pastebin.py b/qutebrowser/misc/pastebin.py
index 0f2ed8ce4..f317670ec 100644
--- a/qutebrowser/misc/pastebin.py
+++ b/qutebrowser/misc/pastebin.py
@@ -60,7 +60,7 @@ class PastebinClient(QObject):
self._client = client
self._api_url = api_url
- def paste(self, name, title, text, parent=None):
+ def paste(self, name, title, text, parent=None, private=False):
"""Paste the text into a pastebin and return the URL.
Args:
@@ -68,6 +68,7 @@ class PastebinClient(QObject):
title: The post title.
text: The text to post.
parent: The parent paste to reply to.
+ private: Whether to paste privately.
"""
data = {
'text': text,
@@ -77,6 +78,9 @@ class PastebinClient(QObject):
}
if parent is not None:
data['reply'] = parent
+ if private:
+ data['private'] = '1'
+
url = QUrl(urllib.parse.urljoin(self._api_url, 'create'))
self._client.post(url, data)
diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py
index a8a652dbb..dddf48b05 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -246,7 +246,7 @@ class SessionManager(QObject):
if tabbed_browser.private:
win_data['private'] = True
for i, tab in enumerate(tabbed_browser.widgets()):
- active = i == tabbed_browser.currentIndex()
+ active = i == tabbed_browser.widget.currentIndex()
win_data['tabs'].append(self._save_tab(tab, active))
data['windows'].append(win_data)
return data
@@ -427,11 +427,12 @@ class SessionManager(QObject):
if tab.get('active', False):
tab_to_focus = i
if new_tab.data.pinned:
- tabbed_browser.set_tab_pinned(new_tab, new_tab.data.pinned)
+ tabbed_browser.widget.set_tab_pinned(new_tab,
+ new_tab.data.pinned)
if tab_to_focus is not None:
- tabbed_browser.setCurrentIndex(tab_to_focus)
+ tabbed_browser.widget.setCurrentIndex(tab_to_focus)
if win.get('active', False):
- QTimer.singleShot(0, tabbed_browser.activateWindow)
+ QTimer.singleShot(0, tabbed_browser.widget.activateWindow)
if data['windows']:
self.did_load = True
diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py
index d2743d56e..4b55eb04e 100644
--- a/qutebrowser/misc/utilcmds.py
+++ b/qutebrowser/misc/utilcmds.py
@@ -185,7 +185,7 @@ def debug_cache_stats():
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window='last-focused')
# pylint: disable=protected-access
- tab_bar = tabbed_browser.tabBar()
+ tab_bar = tabbed_browser.widget.tabBar()
tabbed_browser_info = tab_bar._minimum_tab_size_hint_helper.cache_info()
# pylint: enable=protected-access
diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py
index d4ce3368f..b06444f93 100644
--- a/qutebrowser/utils/jinja.py
+++ b/qutebrowser/utils/jinja.py
@@ -22,12 +22,10 @@
import os
import os.path
import contextlib
-import traceback
import mimetypes
import html
import jinja2
-import jinja2.exceptions
from PyQt5.QtCore import QUrl
from qutebrowser.utils import utils, urlutils, log
@@ -125,14 +123,7 @@ class Environment(jinja2.Environment):
def render(template, **kwargs):
"""Render the given template and pass the given arguments to it."""
- try:
- return environment.get_template(template).render(**kwargs)
- except jinja2.exceptions.UndefinedError:
- log.misc.exception("UndefinedError while rendering " + template)
- err_path = os.path.join('html', 'undef_error.html')
- err_template = utils.read_file(err_path)
- tb = traceback.format_exc()
- return err_template.format(pagename=template, traceback=tb)
+ return environment.get_template(template).render(**kwargs)
environment = Environment()
diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py
index 8d44a9eb5..17fc34b92 100644
--- a/qutebrowser/utils/objreg.py
+++ b/qutebrowser/utils/objreg.py
@@ -171,7 +171,7 @@ def _get_tab_registry(win_id, tab_id):
if tab_id == 'current':
tabbed_browser = get('tabbed-browser', scope='window', window=win_id)
- tab = tabbed_browser.currentWidget()
+ tab = tabbed_browser.widget.currentWidget()
if tab is None:
raise RegistryUnavailableError('window')
tab_id = tab.tab_id
diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py
new file mode 100644
index 000000000..5d9afc13e
--- /dev/null
+++ b/qutebrowser/utils/urlmatch.py
@@ -0,0 +1,293 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""A Chromium-like URL matching pattern.
+
+See:
+https://developer.chrome.com/apps/match_patterns
+https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc
+https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h
+"""
+
+import ipaddress
+import fnmatch
+import urllib.parse
+
+from qutebrowser.utils import utils, qtutils
+
+
+class ParseError(Exception):
+
+ """Raised when a pattern could not be parsed."""
+
+
+class UrlPattern:
+
+ """A Chromium-like URL matching pattern.
+
+ Class attributes:
+ _DEFAULT_PORTS: The default ports used for schemes which support ports.
+ _SCHEMES_WITHOUT_HOST: Schemes which don't need a host.
+
+ Attributes:
+ _pattern: The given pattern as string.
+ _match_all: Whether the pattern should match all URLs.
+ _match_subdomains: Whether the pattern should match subdomains of the
+ given host.
+ _scheme: The scheme to match to, or None to match any scheme.
+ Note that with Chromium, '*'/None only matches http/https and
+ not file/ftp. We deviate from that as per-URL settings aren't
+ security relevant.
+ _host: The host to match to, or None for any host.
+ _path: The path to match to, or None for any path.
+ _port: The port to match to as integer, or None for any port.
+ """
+
+ _DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21}
+ _SCHEMES_WITHOUT_HOST = ['about', 'file', 'data', 'javascript']
+
+ def __init__(self, pattern):
+ # Make sure all attributes are initialized if we exit early.
+ self._pattern = pattern
+ self._match_all = False
+ self._match_subdomains = False
+ self._scheme = None
+ self._host = None
+ self._path = None
+ self._port = None
+
+ # > The special pattern <all_urls> matches any URL that starts with a
+ # > permitted scheme.
+ if pattern == '<all_urls>':
+ self._match_all = True
+ return
+
+ if '\0' in pattern:
+ raise ParseError("May not contain NUL byte")
+
+ pattern = self._fixup_pattern(pattern)
+
+ # We use urllib.parse instead of QUrl here because it can handle
+ # hosts with * in them.
+ try:
+ parsed = urllib.parse.urlparse(pattern)
+ except ValueError as e:
+ raise ParseError(str(e))
+
+ assert parsed is not None
+
+ self._init_scheme(parsed)
+ self._init_host(parsed)
+ self._init_path(parsed)
+ self._init_port(parsed)
+
+ def _to_tuple(self):
+ """Get a pattern with information used for __eq__/__hash__."""
+ return (self._match_all, self._match_subdomains, self._scheme,
+ self._host, self._path, self._port)
+
+ def __hash__(self):
+ return hash(self._to_tuple())
+
+ def __eq__(self, other):
+ if not isinstance(other, UrlPattern):
+ return NotImplemented
+ # pylint: disable=protected-access
+ return self._to_tuple() == other._to_tuple()
+
+ def __repr__(self):
+ return utils.get_repr(self, pattern=self._pattern, constructor=True)
+
+ def __str__(self):
+ return self._pattern
+
+ def _fixup_pattern(self, pattern):
+ """Make sure the given pattern is parseable by urllib.parse."""
+ if pattern.startswith('*:'): # Any scheme, but *:// is unparseable
+ pattern = 'any:' + pattern[2:]
+
+ schemes = tuple(s + ':' for s in self._SCHEMES_WITHOUT_HOST)
+ if '://' not in pattern and not pattern.startswith(schemes):
+ pattern = 'any://' + pattern
+
+ # Chromium handles file://foo like file:///foo
+ # FIXME This doesn't actually strip the hostname correctly.
+ if (pattern.startswith('file://') and
+ not pattern.startswith('file:///')):
+ pattern = 'file:///' + pattern[len("file://"):]
+
+ return pattern
+
+ def _init_scheme(self, parsed):
+ """Parse the scheme from the given URL.
+
+ Deviation from Chromium:
+ - We assume * when no scheme has been given.
+ """
+ assert parsed.scheme, parsed
+ if parsed.scheme == 'any':
+ self._scheme = None
+ return
+
+ self._scheme = parsed.scheme
+
+ def _init_path(self, parsed):
+ """Parse the path from the given URL.
+
+ Deviation from Chromium:
+ - We assume * when no path has been given.
+ """
+ if self._scheme == 'about' and not parsed.path.strip():
+ raise ParseError("Pattern without path")
+
+ if parsed.path == '/*':
+ self._path = None
+ elif parsed.path == '':
+ # When the user doesn't add a trailing slash, we assume the pattern
+ # matches any path.
+ self._path = None
+ else:
+ self._path = parsed.path
+
+ def _init_host(self, parsed):
+ """Parse the host from the given URL.
+
+ Deviation from Chromium:
+ - http://:1234/ is not a valid URL because it has no host.
+ """
+ if parsed.hostname is None or not parsed.hostname.strip():
+ if self._scheme not in self._SCHEMES_WITHOUT_HOST:
+ raise ParseError("Pattern without host")
+ assert self._host is None
+ return
+
+ # FIXME what about multiple dots?
+ host_parts = parsed.hostname.rstrip('.').split('.')
+ if host_parts[0] == '*':
+ host_parts = host_parts[1:]
+ self._match_subdomains = True
+
+ if not host_parts:
+ self._host = None
+ return
+
+ self._host = '.'.join(host_parts)
+
+ if self._host.endswith('.*'):
+ # Special case to have a nicer error
+ raise ParseError("TLD wildcards are not implemented yet")
+ elif '*' in self._host:
+ # Only * or *.foo is allowed as host.
+ raise ParseError("Invalid host wildcard")
+
+ def _init_port(self, parsed):
+ """Parse the port from the given URL.
+
+ Deviation from Chromium:
+ - We use None instead of "*" if there's no port filter.
+ """
+ if parsed.netloc.endswith(':*'):
+ # We can't access parsed.port as it tries to run int()
+ self._port = None
+ elif parsed.netloc.endswith(':'):
+ raise ParseError("Invalid port: Port is empty")
+ else:
+ try:
+ self._port = parsed.port
+ except ValueError as e:
+ raise ParseError("Invalid port: {}".format(e))
+
+ if (self._scheme not in list(self._DEFAULT_PORTS) + [None] and
+ self._port is not None):
+ raise ParseError("Ports are unsupported with {} scheme".format(
+ self._scheme))
+
+ def _matches_scheme(self, scheme):
+ return self._scheme is None or self._scheme == scheme
+
+ def _matches_host(self, host):
+ # FIXME what about multiple dots?
+ host = host.rstrip('.')
+
+ # If we have no host in the match pattern, that means that we're
+ # matching all hosts, which means we have a match no matter what the
+ # test host is.
+ # Contrary to Chromium, we don't need to check for
+ # self._match_subdomains, as we want to return True here for e.g.
+ # file:// as well.
+ if self._host is None:
+ return True
+
+ # If the hosts are exactly equal, we have a match.
+ if host == self._host:
+ return True
+
+ # Otherwise, we can only match if our match pattern matches subdomains.
+ if not self._match_subdomains:
+ return False
+
+ # We don't do subdomain matching against IP addresses, so we can give
+ # up now if the test host is an IP address.
+ if not utils.raises(ValueError, ipaddress.ip_address, host):
+ return False
+
+ # Check if the test host is a subdomain of our host.
+ if len(host) <= (len(self._host) + 1):
+ return False
+
+ if not host.endswith(self._host):
+ return False
+
+ return host[len(host) - len(self._host) - 1] == '.'
+
+ def _matches_port(self, scheme, port):
+ if port == -1 and scheme in self._DEFAULT_PORTS:
+ port = self._DEFAULT_PORTS[scheme]
+ return self._port is None or self._port == port
+
+ def _matches_path(self, path):
+ if self._path is None:
+ return True
+
+ # Match 'google.com' with 'google.com/'
+ if path + '/*' == self._path:
+ return True
+
+ # FIXME Chromium seems to have a more optimized glob matching which
+ # doesn't rely on regexes. Do we need that too?
+ return fnmatch.fnmatchcase(path, self._path)
+
+ def matches(self, qurl):
+ """Check if the pattern matches the given QUrl."""
+ qtutils.ensure_valid(qurl)
+
+ if self._match_all:
+ return True
+
+ if not self._matches_scheme(qurl.scheme()):
+ return False
+ # FIXME ignore for file:// like Chromium?
+ if not self._matches_host(qurl.host()):
+ return False
+ if not self._matches_port(qurl.scheme(), qurl.port()):
+ return False
+ if not self._matches_path(qurl.path()):
+ return False
+
+ return True
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 8312cd803..039d805f9 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -27,6 +27,7 @@ import operator
import collections.abc
import enum
+import attr
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer
from qutebrowser.utils import log, qtutils, utils
@@ -394,3 +395,24 @@ class AbstractCertificateErrorWrapper:
def is_overridable(self):
raise NotImplementedError
+
+
+@attr.s
+class NavigationRequest:
+
+ """A request to navigate to the given URL."""
+
+ Type = enum.Enum('Type', [
+ 'link_clicked',
+ 'typed', # QtWebEngine only
+ 'form_submitted',
+ 'form_resubmitted', # QtWebKit only
+ 'back_forward',
+ 'reloaded',
+ 'other'
+ ])
+
+ url = attr.ib()
+ navigation_type = attr.ib()
+ is_main_frame = attr.ib()
+ accepted = attr.ib(default=True)
diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py
index 079866920..da1ddf085 100644
--- a/qutebrowser/utils/utils.py
+++ b/qutebrowser/utils/utils.py
@@ -26,17 +26,16 @@ import re
import sys
import enum
import json
-import collections
import datetime
import traceback
import functools
import contextlib
import socket
import shlex
+import glob
-import attr
-from PyQt5.QtCore import Qt, QUrl
-from PyQt5.QtGui import QKeySequence, QColor, QClipboard, QDesktopServices
+from PyQt5.QtCore import QUrl
+from PyQt5.QtGui import QColor, QClipboard, QDesktopServices
from PyQt5.QtWidgets import QApplication
import pkg_resources
import yaml
@@ -48,11 +47,12 @@ except ImportError: # pragma: no cover
YAML_C_EXT = False
import qutebrowser
-from qutebrowser.utils import qtutils, log, debug
+from qutebrowser.utils import qtutils, log
fake_clipboard = None
log_clipboard = False
+_resource_cache = {}
is_mac = sys.platform.startswith('darwin')
is_linux = sys.platform.startswith('linux')
@@ -142,6 +142,15 @@ def compact_text(text, elidelength=None):
return out
+def preload_resources():
+ """Load resource files into the cache."""
+ for subdir, pattern in [('html', '*.html'), ('javascript', '*.js')]:
+ path = resource_filename(subdir)
+ for full_path in glob.glob(os.path.join(path, pattern)):
+ sub_path = '/'.join([subdir, os.path.basename(full_path)])
+ _resource_cache[sub_path] = read_file(sub_path)
+
+
def read_file(filename, binary=False):
"""Get the contents of a file contained with qutebrowser.
@@ -153,6 +162,9 @@ def read_file(filename, binary=False):
Return:
The file contents as string.
"""
+ if not binary and filename in _resource_cache:
+ return _resource_cache[filename]
+
if hasattr(sys, 'frozen'):
# PyInstaller doesn't support pkg_resources :(
# https://github.com/pyinstaller/pyinstaller/wiki/FAQ#misc
@@ -285,263 +297,6 @@ def format_size(size, base=1024, suffix=''):
return '{:.02f}{}{}'.format(size, prefixes[-1], suffix)
-def key_to_string(key):
- """Convert a Qt::Key member to a meaningful name.
-
- Args:
- key: A Qt::Key member.
-
- Return:
- A name of the key as a string.
- """
- special_names_str = {
- # Some keys handled in a weird way by QKeySequence::toString.
- # See https://bugreports.qt.io/browse/QTBUG-40030
- # Most are unlikely to be ever needed, but you never know ;)
- # For dead/combining keys, we return the corresponding non-combining
- # key, as that's easier to add to the config.
- 'Key_Blue': 'Blue',
- 'Key_Calendar': 'Calendar',
- 'Key_ChannelDown': 'Channel Down',
- 'Key_ChannelUp': 'Channel Up',
- 'Key_ContrastAdjust': 'Contrast Adjust',
- 'Key_Dead_Abovedot': '˙',
- 'Key_Dead_Abovering': '˚',
- 'Key_Dead_Acute': '´',
- 'Key_Dead_Belowdot': 'Belowdot',
- 'Key_Dead_Breve': '˘',
- 'Key_Dead_Caron': 'ˇ',
- 'Key_Dead_Cedilla': '¸',
- 'Key_Dead_Circumflex': '^',
- 'Key_Dead_Diaeresis': '¨',
- 'Key_Dead_Doubleacute': '˝',
- 'Key_Dead_Grave': '`',
- 'Key_Dead_Hook': 'Hook',
- 'Key_Dead_Horn': 'Horn',
- 'Key_Dead_Iota': 'Iota',
- 'Key_Dead_Macron': '¯',
- 'Key_Dead_Ogonek': '˛',
- 'Key_Dead_Semivoiced_Sound': 'Semivoiced Sound',
- 'Key_Dead_Tilde': '~',
- 'Key_Dead_Voiced_Sound': 'Voiced Sound',
- 'Key_Exit': 'Exit',
- 'Key_Green': 'Green',
- 'Key_Guide': 'Guide',
- 'Key_Info': 'Info',
- 'Key_LaunchG': 'LaunchG',
- 'Key_LaunchH': 'LaunchH',
- 'Key_MediaLast': 'MediaLast',
- 'Key_Memo': 'Memo',
- 'Key_MicMute': 'Mic Mute',
- 'Key_Mode_switch': 'Mode switch',
- 'Key_Multi_key': 'Multi key',
- 'Key_PowerDown': 'Power Down',
- 'Key_Red': 'Red',
- 'Key_Settings': 'Settings',
- 'Key_SingleCandidate': 'Single Candidate',
- 'Key_ToDoList': 'Todo List',
- 'Key_TouchpadOff': 'Touchpad Off',
- 'Key_TouchpadOn': 'Touchpad On',
- 'Key_TouchpadToggle': 'Touchpad toggle',
- 'Key_Yellow': 'Yellow',
- 'Key_Alt': 'Alt',
- 'Key_AltGr': 'AltGr',
- 'Key_Control': 'Control',
- 'Key_Direction_L': 'Direction L',
- 'Key_Direction_R': 'Direction R',
- 'Key_Hyper_L': 'Hyper L',
- 'Key_Hyper_R': 'Hyper R',
- 'Key_Meta': 'Meta',
- 'Key_Shift': 'Shift',
- 'Key_Super_L': 'Super L',
- 'Key_Super_R': 'Super R',
- 'Key_unknown': 'Unknown',
- }
- # We now build our real special_names dict from the string mapping above.
- # The reason we don't do this directly is that certain Qt versions don't
- # have all the keys, so we want to ignore AttributeErrors.
- special_names = {}
- for k, v in special_names_str.items():
- try:
- special_names[getattr(Qt, k)] = v
- except AttributeError:
- pass
- # Now we check if the key is any special one - if not, we use
- # QKeySequence::toString.
- try:
- return special_names[key]
- except KeyError:
- name = QKeySequence(key).toString()
- morphings = {
- 'Backtab': 'Tab',
- 'Esc': 'Escape',
- }
- if name in morphings:
- return morphings[name]
- else:
- return name
-
-
-def keyevent_to_string(e):
- """Convert a QKeyEvent to a meaningful name.
-
- Args:
- e: A QKeyEvent.
-
- Return:
- A name of the key (combination) as a string or
- None if only modifiers are pressed..
- """
- if is_mac:
- # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user
- # can use it in the config as expected. See:
- # https://github.com/qutebrowser/qutebrowser/issues/110
- # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys
- modmask2str = collections.OrderedDict([
- (Qt.MetaModifier, 'Ctrl'),
- (Qt.AltModifier, 'Alt'),
- (Qt.ControlModifier, 'Meta'),
- (Qt.ShiftModifier, 'Shift'),
- ])
- else:
- modmask2str = collections.OrderedDict([
- (Qt.ControlModifier, 'Ctrl'),
- (Qt.AltModifier, 'Alt'),
- (Qt.MetaModifier, 'Meta'),
- (Qt.ShiftModifier, 'Shift'),
- ])
- modifiers = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta,
- Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_Hyper_L,
- Qt.Key_Hyper_R, Qt.Key_Direction_L, Qt.Key_Direction_R)
- if e.key() in modifiers:
- # Only modifier pressed
- return None
- mod = e.modifiers()
- parts = []
- for (mask, s) in modmask2str.items():
- if mod & mask and s not in parts:
- parts.append(s)
- parts.append(key_to_string(e.key()))
- return normalize_keystr('+'.join(parts))
-
-
-@attr.s(repr=False)
-class KeyInfo:
-
- """Stores information about a key, like used in a QKeyEvent.
-
- Attributes:
- key: Qt::Key
- modifiers: Qt::KeyboardModifiers
- text: str
- """
-
- key = attr.ib()
- modifiers = attr.ib()
- text = attr.ib()
-
- def __repr__(self):
- if self.modifiers is None:
- modifiers = None
- else:
- #modifiers = qflags_key(Qt, self.modifiers)
- modifiers = hex(int(self.modifiers))
- return get_repr(self, constructor=True,
- key=debug.qenum_key(Qt, self.key),
- modifiers=modifiers, text=self.text)
-
-
-class KeyParseError(Exception):
-
- """Raised by _parse_single_key/parse_keystring on parse errors."""
-
- def __init__(self, keystr, error):
- super().__init__("Could not parse {!r}: {}".format(keystr, error))
-
-
-def is_special_key(keystr):
- """True if keystr is a 'special' keystring (e.g. <ctrl-x> or <space>)."""
- return keystr.startswith('<') and keystr.endswith('>')
-
-
-def _parse_single_key(keystr):
- """Convert a single key string to a (Qt.Key, Qt.Modifiers, text) tuple."""
- if is_special_key(keystr):
- # Special key
- keystr = keystr[1:-1]
- elif len(keystr) == 1:
- # vim-like key
- pass
- else:
- raise KeyParseError(keystr, "Expecting either a single key or a "
- "<Ctrl-x> like keybinding.")
-
- seq = QKeySequence(normalize_keystr(keystr), QKeySequence.PortableText)
- if len(seq) != 1:
- raise KeyParseError(keystr, "Got {} keys instead of 1.".format(
- len(seq)))
- result = seq[0]
-
- if result == Qt.Key_unknown:
- raise KeyParseError(keystr, "Got unknown key.")
-
- modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier |
- Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier |
- Qt.GroupSwitchModifier)
- assert Qt.Key_unknown & ~modifier_mask == Qt.Key_unknown
-
- modifiers = result & modifier_mask
- key = result & ~modifier_mask
-
- if len(keystr) == 1 and keystr.isupper():
- modifiers |= Qt.ShiftModifier
-
- assert key != 0, key
- key = Qt.Key(key)
- modifiers = Qt.KeyboardModifiers(modifiers)
-
- # Let's hope this is accurate...
- if len(keystr) == 1 and not modifiers:
- text = keystr
- elif len(keystr) == 1 and modifiers == Qt.ShiftModifier:
- text = keystr.upper()
- else:
- text = ''
-
- return KeyInfo(key, modifiers, text)
-
-
-def parse_keystring(keystr):
- """Parse a keystring like <Ctrl-x> or xyz and return a KeyInfo list."""
- if is_special_key(keystr):
- return [_parse_single_key(keystr)]
- else:
- return [_parse_single_key(char) for char in keystr]
-
-
-def normalize_keystr(keystr):
- """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q.
-
- Args:
- keystr: The key combination as a string.
-
- Return:
- The normalized keystring.
- """
- keystr = keystr.lower()
- replacements = (
- ('control', 'ctrl'),
- ('windows', 'meta'),
- ('mod1', 'alt'),
- ('mod4', 'meta'),
- )
- for (orig, repl) in replacements:
- keystr = keystr.replace(orig, repl)
- for mod in ['ctrl', 'meta', 'alt', 'shift']:
- keystr = keystr.replace(mod + '-', mod + '+')
- return keystr
-
-
class FakeIOStream(io.TextIOBase):
"""A fake file-like stream which calls a function for write-calls."""
@@ -915,3 +670,14 @@ def yaml_dump(data, f=None):
return None
else:
return yaml_data.decode('utf-8')
+
+
+def chunk(elems, n):
+ """Yield successive n-sized chunks from elems.
+
+ If elems % n != 0, the last chunk will be smaller.
+ """
+ if n < 1:
+ raise ValueError("n needs to be at least 1!")
+ for i in range(0, len(elems), n):
+ yield elems[i:i + n]
diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py
index 09a1a6efa..016adaa03 100644
--- a/qutebrowser/utils/version.py
+++ b/qutebrowser/utils/version.py
@@ -269,6 +269,8 @@ def _os_info():
else:
versioninfo = '.'.join(versioninfo)
osver = ', '.join([e for e in [release, versioninfo, machine] if e])
+ elif utils.is_posix:
+ osver = ' '.join(platform.uname())
else:
osver = '?'
lines.append('OS Version: {}'.format(osver))
@@ -305,7 +307,19 @@ def _pdfjs_version():
def _chromium_version():
- """Get the Chromium version for QtWebEngine."""
+ """Get the Chromium version for QtWebEngine.
+
+ This can also be checked by looking at this file with the right Qt tag:
+ https://github.com/qt/qtwebengine/blob/dev/tools/scripts/version_resolver.py#L41
+
+ Quick reference:
+ Qt 5.7: Chromium 49
+ Qt 5.8: Chromium 53
+ Qt 5.9: Chromium 56
+ Qt 5.10: Chromium 61
+ Qt 5.11: Chromium 63
+ Qt 5.12: Chromium 65 (?)
+ """
if QWebEngineProfile is None:
# This should never happen
return 'unavailable'
@@ -441,7 +455,13 @@ def opengl_vendor(): # pragma: no cover
vp = QOpenGLVersionProfile()
vp.setVersion(2, 0)
- vf = ctx.versionFunctions(vp)
+ try:
+ vf = ctx.versionFunctions(vp)
+ except ImportError as e:
+ log.init.debug("opengl_vendor: Importing version functions "
+ "failed: {}".format(e))
+ return None
+
if vf is None:
log.init.debug("opengl_vendor: Getting version functions failed!")
return None
@@ -453,7 +473,7 @@ def opengl_vendor(): # pragma: no cover
old_context.makeCurrent(old_surface)
-def pastebin_version():
+def pastebin_version(pbclient=None):
"""Pastebin the version and log the url to messages."""
def _yank_url(url):
utils.set_clipboard(url)
@@ -478,12 +498,13 @@ def pastebin_version():
http_client = httpclient.HTTPClient()
misc_api = pastebin.PastebinClient.MISC_API_URL
- pbclient = pastebin.PastebinClient(http_client, parent=app,
- api_url=misc_api)
+ pbclient = pbclient or pastebin.PastebinClient(http_client, parent=app,
+ api_url=misc_api)
pbclient.success.connect(_on_paste_version_success)
pbclient.error.connect(_on_paste_version_err)
pbclient.paste(getpass.getuser(),
"qute version info {}".format(qutebrowser.__version__),
- version())
+ version(),
+ private=True)