diff options
author | Marc Jauvin <marc.jauvin@gmail.com> | 2018-03-16 14:28:36 -0400 |
---|---|---|
committer | Marc Jauvin <marc.jauvin@gmail.com> | 2018-03-16 14:28:36 -0400 |
commit | b7159d780a69daf104da939438938d262cd86000 (patch) | |
tree | ab9dc382617174d366dae51cda89186cfbd2bcb9 /qutebrowser | |
parent | c9f6cd507b55dabe4d4d8f7841955837a634ff20 (diff) | |
parent | f7074b80d0a68eec6fdfd13f2f82acc94ff2951e (diff) | |
download | qutebrowser-b7159d780a69daf104da939438938d262cd86000.tar.gz qutebrowser-b7159d780a69daf104da939438938d262cd86000.zip |
Merge 'origin/master' into tab-input-mode
Diffstat (limited to 'qutebrowser')
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) |