diff --git a/.pylintrc b/.pylintrc index 2a4ded79f..92720e3a4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -14,6 +14,7 @@ disable=no-self-use, fixme, global-statement, locally-disabled, + locally-enabled, too-many-ancestors, too-few-public-methods, too-many-public-methods, @@ -32,12 +33,13 @@ disable=no-self-use, ungrouped-imports, redefined-variable-type, suppressed-message, - too-many-return-statements + too-many-return-statements, + duplicate-code [BASIC] function-rgx=[a-z_][a-z0-9_]{2,50}$ const-rgx=[A-Za-z_][A-Za-z0-9_]{0,30}$ -method-rgx=[a-z_][A-Za-z0-9_]{2,50}$ +method-rgx=[a-z_][A-Za-z0-9_]{1,50}$ attr-rgx=[a-z_][a-z0-9_]{0,30}$ argument-rgx=[a-z_][a-z0-9_]{0,30}$ variable-rgx=[a-z_][a-z0-9_]{0,30}$ diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py new file mode 100644 index 000000000..2032d680e --- /dev/null +++ b/qutebrowser/browser/browsertab.py @@ -0,0 +1,544 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# 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 . + +"""Base class for a wrapper over QWebView/QWebEngineView.""" + +import itertools + +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject, QPoint +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QWidget, QLayout + +from qutebrowser.keyinput import modeman +from qutebrowser.config import config +from qutebrowser.utils import utils, objreg, usertypes, message + + +tab_id_gen = itertools.count(0) + + +def create(win_id, parent=None): + """Get a QtWebKit/QtWebEngine tab object. + + Args: + win_id: The window ID where the tab will be shown. + parent: The Qt parent to set. + """ + # Importing modules here so we don't depend on QtWebEngine without the + # argument and to avoid circular imports. + mode_manager = modeman.instance(win_id) + if objreg.get('args').backend == 'webengine': + from qutebrowser.browser.webengine import webenginetab + tab_class = webenginetab.WebEngineTab + else: + from qutebrowser.browser.webkit import webkittab + tab_class = webkittab.WebKitTab + return tab_class(win_id=win_id, mode_manager=mode_manager, parent=parent) + + +class WebTabError(Exception): + + """Base class for various errors.""" + + +class WrapperLayout(QLayout): + + """A Qt layout which simply wraps a single widget. + + This is used so the widget is hidden behind a AbstractTab API and can't + easily be accidentally accessed. + """ + + def __init__(self, widget, parent=None): + super().__init__(parent) + self._widget = widget + + def addItem(self, _widget): + raise AssertionError("Should never be called!") + + def sizeHint(self): + return self._widget.sizeHint() + + def itemAt(self, _index): # pragma: no cover + # For some reason this sometimes gets called by Qt. + return None + + def takeAt(self, _index): + raise AssertionError("Should never be called!") + + def setGeometry(self, rect): + self._widget.setGeometry(rect) + + +class TabData: + + """A simple namespace with a fixed set of attributes. + + Attributes: + keep_icon: Whether the (e.g. cloned) icon should not be cleared on page + load. + inspector: The QWebInspector used for this webview. + viewing_source: Set if we're currently showing a source view. + """ + + __slots__ = ['keep_icon', 'viewing_source', 'inspector'] + + def __init__(self): + self.keep_icon = False + self.viewing_source = False + self.inspector = None + + +class AbstractSearch(QObject): + + """Attribute of AbstractTab for doing searches. + + Attributes: + text: The last thing this view was searched for. + _flags: The flags of the last search. + _widget: The underlying WebView widget. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._widget = None + self.text = None + self._flags = 0 + + def search(self, text, *, ignore_case=False, wrap=False, reverse=False): + """Find the given text on the page. + + Args: + text: The text to search for. + ignore_case: Search case-insensitively. (True/False/'smart') + wrap: Wrap around to the top when arriving at the bottom. + reverse: Reverse search direction. + """ + raise NotImplementedError + + def clear(self): + """Clear the current search.""" + raise NotImplementedError + + def prev_result(self): + """Go to the previous result of the current search.""" + raise NotImplementedError + + def next_result(self): + """Go to the next result of the current search.""" + raise NotImplementedError + + +class AbstractZoom(QObject): + + """Attribute of AbstractTab for controlling zoom. + + Attributes: + _neighborlist: A NeighborList with the zoom levels. + _default_zoom_changed: Whether the zoom was changed from the default. + """ + + def __init__(self, win_id, parent=None): + super().__init__(parent) + self._widget = None + self._win_id = win_id + self._default_zoom_changed = False + self._init_neighborlist() + objreg.get('config').changed.connect(self._on_config_changed) + + # # FIXME:qtwebengine is this needed? + # # For some reason, this signal doesn't get disconnected automatically + # # when the WebView is destroyed on older PyQt versions. + # # See https://github.com/The-Compiler/qutebrowser/issues/390 + # self.destroyed.connect(functools.partial( + # cfg.changed.disconnect, self.init_neighborlist)) + + @pyqtSlot(str, str) + def _on_config_changed(self, section, option): + if section == 'ui' and option in ('zoom-levels', 'default-zoom'): + if not self._default_zoom_changed: + factor = float(config.get('ui', 'default-zoom')) / 100 + self._set_factor_internal(factor) + self._default_zoom_changed = False + self._init_neighborlist() + + def _init_neighborlist(self): + """Initialize self._neighborlist.""" + levels = config.get('ui', 'zoom-levels') + self._neighborlist = usertypes.NeighborList( + levels, mode=usertypes.NeighborList.Modes.edge) + self._neighborlist.fuzzyval = config.get('ui', 'default-zoom') + + def offset(self, offset): + """Increase/Decrease the zoom level by the given offset. + + Args: + offset: The offset in the zoom level list. + + Return: + The new zoom percentage. + """ + level = self._neighborlist.getitem(offset) + self.set_factor(float(level) / 100, fuzzyval=False) + return level + + def set_factor(self, factor, *, fuzzyval=True): + """Zoom to a given zoom factor. + + Args: + factor: The zoom factor as float. + fuzzyval: Whether to set the NeighborLists fuzzyval. + """ + if fuzzyval: + self._neighborlist.fuzzyval = int(factor * 100) + if factor < 0: + raise ValueError("Can't zoom to factor {}!".format(factor)) + self._default_zoom_changed = True + self._set_factor_internal(factor) + + def factor(self): + raise NotImplementedError + + def set_default(self): + default_zoom = config.get('ui', 'default-zoom') + self._set_factor_internal(float(default_zoom) / 100) + + @pyqtSlot(QPoint) + def _on_mouse_wheel_zoom(self, delta): + """Handle zooming via mousewheel requested by the web view.""" + divider = config.get('input', 'mouse-zoom-divider') + factor = self.factor() + delta.y() / divider + if factor < 0: + return + perc = int(100 * factor) + message.info(self._win_id, "Zoom level: {}%".format(perc)) + self._neighborlist.fuzzyval = perc + self._set_factor_internal(factor) + self._default_zoom_changed = True + + +class AbstractCaret(QObject): + + """Attribute of AbstractTab for caret browsing.""" + + def __init__(self, win_id, tab, mode_manager, parent=None): + super().__init__(parent) + self._tab = tab + self._win_id = win_id + self._widget = None + self.selection_enabled = False + mode_manager.entered.connect(self._on_mode_entered) + mode_manager.left.connect(self._on_mode_left) + + def _on_mode_entered(self, mode): + raise NotImplementedError + + def _on_mode_left(self): + raise NotImplementedError + + def move_to_next_line(self, count=1): + raise NotImplementedError + + def move_to_prev_line(self, count=1): + raise NotImplementedError + + def move_to_next_char(self, count=1): + raise NotImplementedError + + def move_to_prev_char(self, count=1): + raise NotImplementedError + + def move_to_end_of_word(self, count=1): + raise NotImplementedError + + def move_to_next_word(self, count=1): + raise NotImplementedError + + def move_to_prev_word(self, count=1): + raise NotImplementedError + + def move_to_start_of_line(self): + raise NotImplementedError + + def move_to_end_of_line(self): + raise NotImplementedError + + def move_to_start_of_next_block(self, count=1): + raise NotImplementedError + + def move_to_start_of_prev_block(self, count=1): + raise NotImplementedError + + def move_to_end_of_next_block(self, count=1): + raise NotImplementedError + + def move_to_end_of_prev_block(self, count=1): + raise NotImplementedError + + def move_to_start_of_document(self): + raise NotImplementedError + + def move_to_end_of_document(self): + raise NotImplementedError + + def toggle_selection(self): + raise NotImplementedError + + def drop_selection(self): + raise NotImplementedError + + def has_selection(self): + raise NotImplementedError + + def selection(self, html=False): + raise NotImplementedError + + def follow_selected(self, *, tab=False): + raise NotImplementedError + + +class AbstractScroller(QObject): + + """Attribute of AbstractTab to manage scroll position.""" + + perc_changed = pyqtSignal(int, int) + + def __init__(self, parent=None): + super().__init__(parent) + self._widget = None + + def pos_px(self): + raise NotImplementedError + + def pos_perc(self): + raise NotImplementedError + + def to_perc(self, x=None, y=None): + raise NotImplementedError + + def to_point(self, point): + raise NotImplementedError + + def delta(self, x=0, y=0): + raise NotImplementedError + + def delta_page(self, x=0, y=0): + raise NotImplementedError + + def up(self, count=1): + raise NotImplementedError + + def down(self, count=1): + raise NotImplementedError + + def left(self, count=1): + raise NotImplementedError + + def right(self, count=1): + raise NotImplementedError + + def top(self): + raise NotImplementedError + + def bottom(self): + raise NotImplementedError + + def page_up(self, count=1): + raise NotImplementedError + + def page_down(self, count=1): + raise NotImplementedError + + def at_top(self): + raise NotImplementedError + + def at_bottom(self): + raise NotImplementedError + + +class AbstractHistory: + + """The history attribute of a AbstractTab.""" + + def __init__(self, tab): + self._tab = tab + self._history = None + + def __len__(self): + return len(self._history) + + def __iter__(self): + return iter(self._history.items()) + + def current_idx(self): + raise NotImplementedError + + def back(self): + raise NotImplementedError + + def forward(self): + raise NotImplementedError + + def can_go_back(self): + raise NotImplementedError + + def can_go_forward(self): + raise NotImplementedError + + def serialize(self): + """Serialize into an opaque format understood by self.deserialize.""" + raise NotImplementedError + + def deserialize(self, data): + """Serialize from a format produced by self.serialize.""" + raise NotImplementedError + + def load_items(self, items): + """Deserialize from a list of WebHistoryItems.""" + raise NotImplementedError + + +class AbstractTab(QWidget): + + """A wrapper over the given widget to hide its API and expose another one. + + We use this to unify QWebView and QWebEngineView. + + Attributes: + history: The AbstractHistory for the current tab. + registry: The ObjectRegistry associated with this tab. + + for properties, see WebView/WebEngineView docs. + + Signals: + See related Qt signals. + + new_tab_requested: Emitted when a new tab should be opened with the + given URL. + """ + + window_close_requested = pyqtSignal() + link_hovered = pyqtSignal(str) + load_started = pyqtSignal() + load_progress = pyqtSignal(int) + load_finished = pyqtSignal(bool) + icon_changed = pyqtSignal(QIcon) + # FIXME:qtwebengine get rid of this altogether? + url_text_changed = pyqtSignal(str) + title_changed = pyqtSignal(str) + load_status_changed = pyqtSignal(str) + new_tab_requested = pyqtSignal(QUrl) + shutting_down = pyqtSignal() + + def __init__(self, win_id, parent=None): + self.win_id = win_id + self.tab_id = next(tab_id_gen) + super().__init__(parent) + + self.registry = objreg.ObjectRegistry() + tab_registry = objreg.get('tab-registry', scope='window', + window=win_id) + tab_registry[self.tab_id] = self + objreg.register('tab', self, registry=self.registry) + + # self.history = AbstractHistory(self) + # self.scroll = AbstractScroller(parent=self) + # self.caret = AbstractCaret(win_id=win_id, tab=self, mode_manager=..., + # parent=self) + # self.zoom = AbstractZoom(win_id=win_id) + # self.search = AbstractSearch(parent=self) + self.data = TabData() + self._layout = None + self._widget = None + self.backend = None + + def _set_widget(self, widget): + # pylint: disable=protected-access + self._layout = WrapperLayout(widget, self) + self._widget = widget + self.history._history = widget.history() + self.scroll._widget = widget + self.caret._widget = widget + self.zoom._widget = widget + self.search._widget = widget + widget.mouse_wheel_zoom.connect(self.zoom._on_mouse_wheel_zoom) + widget.setParent(self) + self.setFocusProxy(widget) + + @pyqtSlot() + def _on_load_started(self): + self.data.viewing_source = False + self.load_started.emit() + + def url(self): + raise NotImplementedError + + def progress(self): + raise NotImplementedError + + def load_status(self): + raise NotImplementedError + + def openurl(self, url): + raise NotImplementedError + + def reload(self, *, force=False): + raise NotImplementedError + + def stop(self): + raise NotImplementedError + + def clear_ssl_errors(self): + raise NotImplementedError + + def dump_async(self, callback, *, plain=False): + """Dump the current page to a file ascync. + + The given callback will be called with the result when dumping is + complete. + """ + raise NotImplementedError + + def run_js_async(self, code, callback=None): + """Run javascript async. + + The given callback will be called with the result when running JS is + complete. + """ + raise NotImplementedError + + def shutdown(self): + raise NotImplementedError + + def title(self): + raise NotImplementedError + + def icon(self): + raise NotImplementedError + + def set_html(self, html, base_url): + raise NotImplementedError + + def __repr__(self): + try: + url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), + 100) + except AttributeError: + url = '' + return utils.get_repr(self, tab_id=self.tab_id, url=url) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 460b6ffe8..631f7898b 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -21,25 +21,26 @@ import os import os.path -import sys import shlex import posixpath import functools -import xml.etree.ElementTree -from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWidgets import QApplication, QTabBar from PyQt5.QtCore import Qt, QUrl, QEvent from PyQt5.QtGui import QKeyEvent from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog from PyQt5.QtWebKitWidgets import QWebPage +try: + from PyQt5.QtWebEngineWidgets import QWebEnginePage +except ImportError: + QWebEnginePage = None import pygments import pygments.lexers import pygments.formatters from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.config import config, configexc -from qutebrowser.browser import urlmarks +from qutebrowser.browser import urlmarks, browsertab from qutebrowser.browser.webkit import webelem, inspector, downloads, mhtml from qutebrowser.keyinput import modeman from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, @@ -281,10 +282,7 @@ class CommandDispatcher: """ tab = self._cntwidget(count) if tab is not None: - if force: - tab.page().triggerAction(QWebPage.ReloadAndBypassCache) - else: - tab.reload() + tab.reload(force=force) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', count=True) @@ -356,10 +354,9 @@ class CommandDispatcher: new_tabbed_browser.setTabIcon(idx, curtab.icon()) if config.get('tabs', 'tabs-are-windows'): new_tabbed_browser.window().setWindowIcon(curtab.icon()) - newtab.keep_icon = True - newtab.setZoomFactor(curtab.zoomFactor()) - history = qtutils.serialize(curtab.history()) - qtutils.deserialize(history, newtab.history()) + newtab.data.keep_icon = True + newtab.zoom.set_factor(curtab.zoom.factor()) + newtab.history.deserialize(curtab.history.serialize()) return newtab @cmdutils.register(instance='command-dispatcher', scope='window') @@ -372,11 +369,11 @@ class CommandDispatcher: def _back_forward(self, tab, bg, window, count, forward): """Helper function for :back/:forward.""" + history = self._current_widget().history # Catch common cases before e.g. cloning tab - history = self._current_widget().page().history() - if not forward and not history.canGoBack(): + if not forward and not history.can_go_back(): raise cmdexc.CommandError("At beginning of history.") - elif forward and not history.canGoForward(): + elif forward and not history.can_go_forward(): raise cmdexc.CommandError("At end of history.") if tab or bg or window: @@ -384,16 +381,15 @@ class CommandDispatcher: else: widget = self._current_widget() - history = widget.page().history() for _ in range(count): if forward: - if not history.canGoForward(): + if not widget.history.can_go_forward(): raise cmdexc.CommandError("At end of history.") - widget.forward() + widget.history.forward() else: - if not history.canGoBack(): + if not widget.history.can_go_back(): raise cmdexc.CommandError("At beginning of history.") - widget.back() + widget.history.back() @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', count=True) @@ -455,7 +451,8 @@ class CommandDispatcher: url.setPath(new_path) self._open(url, tab, background, window) - @cmdutils.register(instance='command-dispatcher', scope='window') + @cmdutils.register(instance='command-dispatcher', scope='window', + backend=usertypes.Backend.QtWebKit) @cmdutils.argument('where', choices=['prev', 'next', 'up', 'increment', 'decrement']) def navigate(self, where: str, tab=False, bg=False, window=False): @@ -484,15 +481,27 @@ class CommandDispatcher: cmdutils.check_exclusive((tab, bg, window), 'tbw') widget = self._current_widget() - frame = widget.page().currentFrame() url = self._current_url().adjusted(QUrl.RemoveFragment) - if frame is None: - raise cmdexc.CommandError("No frame focused!") + + if where in ['prev', 'next']: + # FIXME:qtwebengine have a proper API for this + if widget.backend == usertypes.Backend.QtWebEngine: + raise cmdexc.CommandError(":navigate prev/next is not " + "supported yet with QtWebEngine") + page = widget._widget.page() # pylint: disable=protected-access + frame = page.currentFrame() + if frame is None: + raise cmdexc.CommandError("No frame focused!") + else: + frame = None + hintmanager = objreg.get('hintmanager', scope='tab', tab='current') if where == 'prev': + assert frame is not None hintmanager.follow_prevnext(frame, url, prev=True, tab=tab, background=bg, window=window) elif where == 'next': + assert frame is not None hintmanager.follow_prevnext(frame, url, prev=False, tab=tab, background=bg, window=window) elif where == 'up': @@ -518,7 +527,7 @@ class CommandDispatcher: dy *= count cmdutils.check_overflow(dx, 'int') cmdutils.check_overflow(dy, 'int') - self._current_widget().page().currentFrame().scroll(dx, dy) + self._current_widget().scroll.delta(dx, dy) @cmdutils.register(instance='command-dispatcher', hide=True, scope='window') @@ -531,54 +540,29 @@ class CommandDispatcher: (up/down/left/right/top/bottom). count: multiplier """ - fake_keys = { - 'up': Qt.Key_Up, - 'down': Qt.Key_Down, - 'left': Qt.Key_Left, - 'right': Qt.Key_Right, - 'top': Qt.Key_Home, - 'bottom': Qt.Key_End, - 'page-up': Qt.Key_PageUp, - 'page-down': Qt.Key_PageDown, + tab = self._current_widget() + funcs = { + 'up': tab.scroll.up, + 'down': tab.scroll.down, + 'left': tab.scroll.left, + 'right': tab.scroll.right, + 'top': tab.scroll.top, + 'bottom': tab.scroll.bottom, + 'page-up': tab.scroll.page_up, + 'page-down': tab.scroll.page_down, } try: - key = fake_keys[direction] + func = funcs[direction] except KeyError: - expected_values = ', '.join(sorted(fake_keys)) + expected_values = ', '.join(sorted(funcs)) raise cmdexc.CommandError("Invalid value {!r} for direction - " "expected one of: {}".format( direction, expected_values)) - widget = self._current_widget() - frame = widget.page().currentFrame() - press_evt = QKeyEvent(QEvent.KeyPress, key, Qt.NoModifier, 0, 0, 0) - release_evt = QKeyEvent(QEvent.KeyRelease, key, Qt.NoModifier, 0, 0, 0) - - # Count doesn't make sense with top/bottom if direction in ('top', 'bottom'): - count = 1 - - max_min = { - 'up': [Qt.Vertical, frame.scrollBarMinimum], - 'down': [Qt.Vertical, frame.scrollBarMaximum], - 'left': [Qt.Horizontal, frame.scrollBarMinimum], - 'right': [Qt.Horizontal, frame.scrollBarMaximum], - 'page-up': [Qt.Vertical, frame.scrollBarMinimum], - 'page-down': [Qt.Vertical, frame.scrollBarMaximum], - } - - for _ in range(count): - # Abort scrolling if the minimum/maximum was reached. - try: - qt_dir, getter = max_min[direction] - except KeyError: - pass - else: - if frame.scrollBarValue(qt_dir) == getter(qt_dir): - return - - widget.keyPressEvent(press_evt) - widget.keyReleaseEvent(release_evt) + func() + else: + func(count=count) @cmdutils.register(instance='command-dispatcher', hide=True, scope='window') @@ -603,19 +587,14 @@ class CommandDispatcher: elif count is not None: perc = count - orientation = Qt.Horizontal if horizontal else Qt.Vertical - - if perc == 0 and orientation == Qt.Vertical: - self.scroll('top') - elif perc == 100 and orientation == Qt.Vertical: - self.scroll('bottom') + if horizontal: + x = perc + y = None else: - perc = qtutils.check_overflow(perc, 'int', fatal=False) - frame = self._current_widget().page().currentFrame() - m = frame.scrollBarMaximum(orientation) - if m == 0: - return - frame.setScrollBarValue(orientation, int(m * perc / 100)) + x = None + y = perc + + self._current_widget().scroll.to_perc(x, y) @cmdutils.register(instance='command-dispatcher', hide=True, scope='window') @@ -638,38 +617,24 @@ class CommandDispatcher: scrolling up at the top of the page. count: multiplier """ - frame = self._current_widget().page().currentFrame() - if not frame.url().isValid(): + tab = self._current_widget() + if not tab.url().isValid(): # See https://github.com/The-Compiler/qutebrowser/issues/701 return - if (bottom_navigate is not None and - frame.scrollPosition().y() >= - frame.scrollBarMaximum(Qt.Vertical)): + if bottom_navigate is not None and tab.scroll.at_bottom(): self.navigate(bottom_navigate) return - elif top_navigate is not None and frame.scrollPosition().y() == 0: + elif top_navigate is not None and tab.scroll.at_top(): self.navigate(top_navigate) return - mult_x = count * x - mult_y = count * y - if mult_y.is_integer(): - if mult_y == 0: - pass - elif mult_y < 0: - self.scroll('page-up', count=-int(mult_y)) - elif mult_y > 0: # pragma: no branch - self.scroll('page-down', count=int(mult_y)) - mult_y = 0 - if mult_x == 0 and mult_y == 0: - return - size = frame.geometry() - dx = mult_x * size.width() - dy = mult_y * size.height() - cmdutils.check_overflow(dx, 'int') - cmdutils.check_overflow(dy, 'int') - frame.scroll(dx, dy) + try: + tab.scroll.delta_page(count * x, count * y) + except OverflowError: + raise cmdexc.CommandError( + "Numeric argument is too large for internal int " + "representation.") @cmdutils.register(instance='command-dispatcher', scope='window') def yank(self, title=False, sel=False, domain=False, pretty=False): @@ -717,7 +682,7 @@ class CommandDispatcher: """ tab = self._current_widget() try: - perc = tab.zoom(count) + perc = tab.zoom.offset(count) except ValueError as e: raise cmdexc.CommandError(e) message.info(self._win_id, "Zoom level: {}%".format(perc)) @@ -732,7 +697,7 @@ class CommandDispatcher: """ tab = self._current_widget() try: - perc = tab.zoom(-count) + perc = tab.zoom.offset(-count) except ValueError as e: raise cmdexc.CommandError(e) message.info(self._win_id, "Zoom level: {}%".format(perc)) @@ -757,9 +722,9 @@ class CommandDispatcher: tab = self._current_widget() try: - tab.zoom_perc(level) - except ValueError as e: - raise cmdexc.CommandError(e) + tab.zoom.set_factor(float(level) / 100) + except ValueError: + raise cmdexc.CommandError("Can't zoom {}%!".format(level)) message.info(self._win_id, "Zoom level: {}%".format(level)) @cmdutils.register(instance='command-dispatcher', scope='window') @@ -1070,14 +1035,12 @@ class CommandDispatcher: if idx != -1: env['QUTE_TITLE'] = self._tabbed_browser.page_title(idx) - webview = self._tabbed_browser.currentWidget() - if webview is None: - mainframe = None - else: - if webview.hasSelection(): - env['QUTE_SELECTED_TEXT'] = webview.selectedText() - env['QUTE_SELECTED_HTML'] = webview.selectedHtml() - mainframe = webview.page().mainFrame() + tab = self._tabbed_browser.currentWidget() + if tab is not None and tab.caret.has_selection(): + env['QUTE_SELECTED_TEXT'] = tab.caret.selection() + env['QUTE_SELECTED_HTML'] = tab.caret.selection(html=True) + + # FIXME:qtwebengine: If tab is None, run_async will fail! try: url = self._tabbed_browser.current_url() @@ -1086,9 +1049,11 @@ class CommandDispatcher: else: env['QUTE_URL'] = url.toString(QUrl.FullyEncoded) - env.update(userscripts.store_source(mainframe)) - userscripts.run(cmd, *args, win_id=self._win_id, env=env, - verbose=verbose) + try: + userscripts.run_async(tab, cmd, *args, win_id=self._win_id, + env=env, verbose=verbose) + except userscripts.UnsupportedError as e: + raise cmdexc.CommandError(e) @cmdutils.register(instance='command-dispatcher', scope='window') def quickmark_save(self): @@ -1148,65 +1113,48 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', hide=True, scope='window') - def follow_selected(self, tab=False): + def follow_selected(self, *, tab=False): """Follow the selected text. Args: tab: Load the selected link in a new tab. """ - widget = self._current_widget() - page = widget.page() - if not page.hasSelection(): - return - if QWebSettings.globalSettings().testAttribute( - QWebSettings.JavascriptEnabled): - if tab: - page.open_target = usertypes.ClickTarget.tab - page.currentFrame().evaluateJavaScript( - 'window.getSelection().anchorNode.parentNode.click()') - else: - try: - selected_element = xml.etree.ElementTree.fromstring( - '' + widget.selectedHtml() + '').find('a') - except xml.etree.ElementTree.ParseError: - raise cmdexc.CommandError('Could not parse selected element!') - - if selected_element is not None: - try: - url = selected_element.attrib['href'] - except KeyError: - raise cmdexc.CommandError('Anchor element without href!') - url = self._current_url().resolved(QUrl(url)) - self._open(url, tab) + try: + self._current_widget().caret.follow_selected(tab=tab) + except browsertab.WebTabError as e: + raise cmdexc.CommandError(str(e)) @cmdutils.register(instance='command-dispatcher', name='inspector', - scope='window') + scope='window', backend=usertypes.Backend.QtWebKit) def toggle_inspector(self): """Toggle the web inspector. Note: Due a bug in Qt, the inspector will show incorrect request headers in the network tab. """ - cur = self._current_widget() - if cur.inspector is None: + tab = self._current_widget() + if tab.data.inspector is None: if not config.get('general', 'developer-extras'): raise cmdexc.CommandError( "Please enable developer-extras before using the " "webinspector!") - cur.inspector = inspector.WebInspector() - cur.inspector.setPage(cur.page()) - cur.inspector.show() - elif cur.inspector.isVisible(): - cur.inspector.hide() + tab.data.inspector = inspector.WebInspector() + # FIXME:qtwebengine have a proper API for this + page = tab._widget.page() # pylint: disable=protected-access + tab.data.inspector.setPage(page) + tab.data.inspector.show() + elif tab.data.inspector.isVisible(): + tab.data.inspector.hide() else: if not config.get('general', 'developer-extras'): raise cmdexc.CommandError( "Please enable developer-extras before using the " "webinspector!") else: - cur.inspector.show() + tab.data.inspector.show() - @cmdutils.register(instance='command-dispatcher', scope='window') + @cmdutils.register(instance='command-dispatcher', scope='window', + backend=usertypes.Backend.QtWebKit) @cmdutils.argument('dest_old', hide=True) def download(self, url=None, dest_old=None, *, mhtml_=False, dest=None): """Download a given URL, or current page if no URL given. @@ -1238,13 +1186,14 @@ class CommandDispatcher: url = urlutils.qurl_from_user_input(url) urlutils.raise_cmdexc_if_invalid(url) download_manager.get(url, filename=dest) + elif mhtml_: + self._download_mhtml(dest) else: - if mhtml_: - self._download_mhtml(dest) - else: - page = self._current_widget().page() - download_manager.get(self._current_url(), page=page, - filename=dest) + # FIXME:qtwebengine have a proper API for this + tab = self._current_widget() + page = tab._widget.page() # pylint: disable=protected-access + download_manager.get(self._current_url(), page=page, + filename=dest) def _download_mhtml(self, dest=None): """Download the current page as an MHTML file, including all assets. @@ -1252,40 +1201,43 @@ class CommandDispatcher: Args: dest: The file path to write the download to. """ - web_view = self._current_widget() + tab = self._current_widget() if dest is None: suggested_fn = self._current_title() + ".mht" suggested_fn = utils.sanitize_filename(suggested_fn) filename, q = downloads.ask_for_filename( - suggested_fn, self._win_id, parent=web_view, + suggested_fn, self._win_id, parent=tab, ) if filename is not None: - mhtml.start_download_checked(filename, web_view=web_view) + mhtml.start_download_checked(filename, tab=tab) else: q.answered.connect(functools.partial( - mhtml.start_download_checked, web_view=web_view)) + mhtml.start_download_checked, tab=tab)) q.ask() else: - mhtml.start_download_checked(dest, web_view=web_view) + mhtml.start_download_checked(dest, tab=tab) @cmdutils.register(instance='command-dispatcher', scope='window') def view_source(self): """Show the source of the current page.""" # pylint: disable=no-member # WORKAROUND for https://bitbucket.org/logilab/pylint/issue/491/ - widget = self._current_widget() - if widget.viewing_source: + tab = self._current_widget() + if tab.data.viewing_source: raise cmdexc.CommandError("Already viewing source!") - frame = widget.page().currentFrame() - html = frame.toHtml() - lexer = pygments.lexers.HtmlLexer() - formatter = pygments.formatters.HtmlFormatter(full=True, - linenos='table') - highlighted = pygments.highlight(html, lexer, formatter) - current_url = self._current_url() - tab = self._tabbed_browser.tabopen(explicit=True) - tab.setHtml(highlighted, current_url) - tab.viewing_source = True + + def show_source_cb(source): + """Show source as soon as it's ready.""" + lexer = pygments.lexers.HtmlLexer() + formatter = pygments.formatters.HtmlFormatter(full=True, + linenos='table') + highlighted = pygments.highlight(source, lexer, formatter) + current_url = self._current_url() + new_tab = self._tabbed_browser.tabopen(explicit=True) + new_tab.set_html(highlighted, current_url) + new_tab.data.viewing_source = True + + tab.dump_async(show_source_cb) @cmdutils.register(instance='command-dispatcher', scope='window', debug=True) @@ -1296,22 +1248,20 @@ class CommandDispatcher: dest: Where to write the file to. plain: Write plain text instead of HTML. """ - web_view = self._current_widget() - mainframe = web_view.page().mainFrame() - if plain: - data = mainframe.toPlainText() - else: - data = mainframe.toHtml() - + tab = self._current_widget() dest = os.path.expanduser(dest) - try: - with open(dest, 'w', encoding='utf-8') as f: - f.write(data) - except OSError as e: - raise cmdexc.CommandError('Could not write page: {}'.format(e)) - else: - message.info(self._win_id, "Dumped page to {}.".format(dest)) + def callback(data): + try: + with open(dest, 'w', encoding='utf-8') as f: + f.write(data) + except OSError as e: + message.error(self._win_id, + 'Could not write page: {}'.format(e)) + else: + message.info(self._win_id, "Dumped page to {}.".format(dest)) + + tab.dump_async(callback, plain=plain) @cmdutils.register(instance='command-dispatcher', name='help', scope='window') @@ -1377,16 +1327,19 @@ class CommandDispatcher: self._open(url, tab, bg, window) @cmdutils.register(instance='command-dispatcher', - modes=[KeyMode.insert], hide=True, scope='window') + modes=[KeyMode.insert], hide=True, scope='window', + backend=usertypes.Backend.QtWebKit) def open_editor(self): """Open an external editor with the currently selected form field. The editor which should be launched can be configured via the `general -> editor` config option. """ - frame = self._current_widget().page().currentFrame() + # FIXME:qtwebengine have a proper API for this + tab = self._current_widget() + page = tab._widget.page() # pylint: disable=protected-access try: - elem = webelem.focus_elem(frame) + elem = webelem.focus_elem(page.currentFrame()) except webelem.IsNullError: raise cmdexc.CommandError("No element focused!") if not elem.is_editable(strict=True): @@ -1424,12 +1377,14 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', modes=[KeyMode.insert], hide=True, scope='window', - needs_js=True) + needs_js=True, backend=usertypes.Backend.QtWebKit) def paste_primary(self): """Paste the primary selection at cursor position.""" - frame = self._current_widget().page().currentFrame() + # FIXME:qtwebengine have a proper API for this + tab = self._current_widget() + page = tab._widget.page() # pylint: disable=protected-access try: - elem = webelem.focus_elem(frame) + elem = webelem.focus_elem(page.currentFrame()) except webelem.IsNullError: raise cmdexc.CommandError("No element focused!") if not elem.is_editable(strict=True): @@ -1449,17 +1404,6 @@ class CommandDispatcher: this.dispatchEvent(event); """.format(webelem.javascript_escape(sel))) - def _clear_search(self, view, text): - """Clear search string/highlights for the given view. - - This does nothing if the view's search text is the same as the given - text. - """ - if view.search_text is not None and view.search_text != text: - # We first clear the marked text, then the highlights - view.search('', 0) - view.search('', QWebPage.HighlightAllOccurrences) - @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) def search(self, text="", reverse=False): @@ -1470,27 +1414,18 @@ class CommandDispatcher: reverse: Reverse search direction. """ self.set_mark("'") - view = self._current_widget() - self._clear_search(view, text) - flags = 0 - ignore_case = config.get('general', 'ignore-case') - if ignore_case == 'smart': - if not text.islower(): - flags |= QWebPage.FindCaseSensitively - elif not ignore_case: - flags |= QWebPage.FindCaseSensitively - if config.get('general', 'wrap-search'): - flags |= QWebPage.FindWrapsAroundDocument - if reverse: - flags |= QWebPage.FindBackward - # We actually search *twice* - once to highlight everything, then again - # to get a mark so we can navigate. - view.search(text, flags) - view.search(text, flags | QWebPage.HighlightAllOccurrences) - view.search_text = text - view.search_flags = flags + tab = self._current_widget() + tab.search.clear() + + options = { + 'ignore_case': config.get('general', 'ignore-case'), + 'wrap': config.get('general', 'wrap-search'), + 'reverse': reverse, + } + tab.search.search(text, **options) + self._tabbed_browser.search_text = text - self._tabbed_browser.search_flags = flags + self._tabbed_browser.search_options = options @cmdutils.register(instance='command-dispatcher', hide=True, scope='window') @@ -1501,18 +1436,19 @@ class CommandDispatcher: Args: count: How many elements to ignore. """ + tab = self._current_widget() + window_text = self._tabbed_browser.search_text + window_options = self._tabbed_browser.search_options + self.set_mark("'") - view = self._current_widget() - self._clear_search(view, self._tabbed_browser.search_text) + if window_text is not None and window_text != tab.search.text: + tab.search.clear() + tab.search.search(window_text, **window_options) + count -= 1 - if self._tabbed_browser.search_text is not None: - view.search_text = self._tabbed_browser.search_text - view.search_flags = self._tabbed_browser.search_flags - view.search(view.search_text, - view.search_flags | QWebPage.HighlightAllOccurrences) - for _ in range(count): - view.search(view.search_text, view.search_flags) + for _ in range(count): + tab.search.next_result() @cmdutils.register(instance='command-dispatcher', hide=True, scope='window') @@ -1523,25 +1459,19 @@ class CommandDispatcher: Args: count: How many elements to ignore. """ - self.set_mark("'") - view = self._current_widget() - self._clear_search(view, self._tabbed_browser.search_text) + tab = self._current_widget() + window_text = self._tabbed_browser.search_text + window_options = self._tabbed_browser.search_options + + self.set_mark("'") + + if window_text is not None and window_text != tab.search.text: + tab.search.clear() + tab.search.search(window_text, **window_options) + count -= 1 - if self._tabbed_browser.search_text is not None: - view.search_text = self._tabbed_browser.search_text - view.search_flags = self._tabbed_browser.search_flags - view.search(view.search_text, - view.search_flags | QWebPage.HighlightAllOccurrences) - # The int() here serves as a QFlags constructor to create a copy of the - # QFlags instance rather as a reference. I don't know why it works this - # way, but it does. - flags = int(view.search_flags) - if flags & QWebPage.FindBackward: - flags &= ~QWebPage.FindBackward - else: - flags |= QWebPage.FindBackward for _ in range(count): - view.search(view.search_text, flags) + tab.search.prev_result() @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1552,13 +1482,7 @@ class CommandDispatcher: Args: count: How many lines to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToNextLine - else: - act = QWebPage.SelectNextLine - for _ in range(count): - webview.triggerPageAction(act) + self._current_widget().caret.move_to_next_line(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1569,13 +1493,7 @@ class CommandDispatcher: Args: count: How many lines to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToPreviousLine - else: - act = QWebPage.SelectPreviousLine - for _ in range(count): - webview.triggerPageAction(act) + self._current_widget().caret.move_to_prev_line(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1586,13 +1504,7 @@ class CommandDispatcher: Args: count: How many lines to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToNextChar - else: - act = QWebPage.SelectNextChar - for _ in range(count): - webview.triggerPageAction(act) + self._current_widget().caret.move_to_next_char(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1603,13 +1515,7 @@ class CommandDispatcher: Args: count: How many chars to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToPreviousChar - else: - act = QWebPage.SelectPreviousChar - for _ in range(count): - webview.triggerPageAction(act) + self._current_widget().caret.move_to_prev_char(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1620,18 +1526,7 @@ class CommandDispatcher: Args: count: How many words to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = [QWebPage.MoveToNextWord] - if sys.platform == 'win32': # pragma: no cover - act.append(QWebPage.MoveToPreviousChar) - else: - act = [QWebPage.SelectNextWord] - if sys.platform == 'win32': # pragma: no cover - act.append(QWebPage.SelectPreviousChar) - for _ in range(count): - for a in act: - webview.triggerPageAction(a) + self._current_widget().caret.move_to_end_of_word(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1642,18 +1537,7 @@ class CommandDispatcher: Args: count: How many words to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = [QWebPage.MoveToNextWord] - if sys.platform != 'win32': # pragma: no branch - act.append(QWebPage.MoveToNextChar) - else: - act = [QWebPage.SelectNextWord] - if sys.platform != 'win32': # pragma: no branch - act.append(QWebPage.SelectNextChar) - for _ in range(count): - for a in act: - webview.triggerPageAction(a) + self._current_widget().caret.move_to_next_word(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1664,35 +1548,19 @@ class CommandDispatcher: Args: count: How many words to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToPreviousWord - else: - act = QWebPage.SelectPreviousWord - for _ in range(count): - webview.triggerPageAction(act) + self._current_widget().caret.move_to_prev_word(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') def move_to_start_of_line(self): """Move the cursor or selection to the start of the line.""" - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToStartOfLine - else: - act = QWebPage.SelectStartOfLine - webview.triggerPageAction(act) + self._current_widget().caret.move_to_start_of_line() @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') def move_to_end_of_line(self): """Move the cursor or selection to the end of line.""" - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToEndOfLine - else: - act = QWebPage.SelectEndOfLine - webview.triggerPageAction(act) + self._current_widget().caret.move_to_end_of_line() @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1703,16 +1571,7 @@ class CommandDispatcher: Args: count: How many blocks to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = [QWebPage.MoveToNextLine, - QWebPage.MoveToStartOfBlock] - else: - act = [QWebPage.SelectNextLine, - QWebPage.SelectStartOfBlock] - for _ in range(count): - for a in act: - webview.triggerPageAction(a) + self._current_widget().caret.move_to_start_of_next_block(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1723,16 +1582,7 @@ class CommandDispatcher: Args: count: How many blocks to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = [QWebPage.MoveToPreviousLine, - QWebPage.MoveToStartOfBlock] - else: - act = [QWebPage.SelectPreviousLine, - QWebPage.SelectStartOfBlock] - for _ in range(count): - for a in act: - webview.triggerPageAction(a) + self._current_widget().caret.move_to_start_of_prev_block(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1743,16 +1593,7 @@ class CommandDispatcher: Args: count: How many blocks to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = [QWebPage.MoveToNextLine, - QWebPage.MoveToEndOfBlock] - else: - act = [QWebPage.SelectNextLine, - QWebPage.SelectEndOfBlock] - for _ in range(count): - for a in act: - webview.triggerPageAction(a) + self._current_widget().caret.move_to_end_of_next_block(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') @@ -1763,36 +1604,19 @@ class CommandDispatcher: Args: count: How many blocks to move. """ - webview = self._current_widget() - if not webview.selection_enabled: - act = [QWebPage.MoveToPreviousLine, QWebPage.MoveToEndOfBlock] - else: - act = [QWebPage.SelectPreviousLine, QWebPage.SelectEndOfBlock] - for _ in range(count): - for a in act: - webview.triggerPageAction(a) + self._current_widget().caret.move_to_end_of_prev_block(count) @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') def move_to_start_of_document(self): """Move the cursor or selection to the start of the document.""" - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToStartOfDocument - else: - act = QWebPage.SelectStartOfDocument - webview.triggerPageAction(act) + self._current_widget().caret.move_to_start_of_document() @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') def move_to_end_of_document(self): """Move the cursor or selection to the end of the document.""" - webview = self._current_widget() - if not webview.selection_enabled: - act = QWebPage.MoveToEndOfDocument - else: - act = QWebPage.SelectEndOfDocument - webview.triggerPageAction(act) + self._current_widget().caret.move_to_end_of_document() @cmdutils.register(instance='command-dispatcher', scope='window') def yank_selected(self, sel=False, keep=False): @@ -1802,8 +1626,9 @@ class CommandDispatcher: sel: Use the primary selection instead of the clipboard. keep: If given, stay in visual mode after yanking. """ - s = self._current_widget().selectedText() - if not self._current_widget().hasSelection() or len(s) == 0: + caret = self._current_widget().caret + s = caret.selection() + if not caret.has_selection() or len(s) == 0: message.info(self._win_id, "Nothing to yank") return @@ -1822,17 +1647,13 @@ class CommandDispatcher: modes=[KeyMode.caret], scope='window') def toggle_selection(self): """Toggle caret selection mode.""" - widget = self._current_widget() - widget.selection_enabled = not widget.selection_enabled - mainwindow = objreg.get('main-window', scope='window', - window=self._win_id) - mainwindow.status.set_mode_active(usertypes.KeyMode.caret, True) + self._current_widget().caret.toggle_selection() @cmdutils.register(instance='command-dispatcher', hide=True, modes=[KeyMode.caret], scope='window') def drop_selection(self): """Drop selection and keep selection mode enabled.""" - self._current_widget().triggerPageAction(QWebPage.MoveToNextChar) + self._current_widget().caret.drop_selection() @cmdutils.register(instance='command-dispatcher', scope='window', debug=True) @@ -1847,13 +1668,26 @@ class CommandDispatcher: action: The action to execute, e.g. MoveToNextChar. count: How many times to repeat the action. """ - member = getattr(QWebPage, action, None) - if not isinstance(member, QWebPage.WebAction): + tab = self._current_widget() + + if tab.backend == usertypes.Backend.QtWebKit: + assert QWebPage is not None + member = getattr(QWebPage, action, None) + base = QWebPage.WebAction + elif tab.backend == usertypes.Backend.QtWebEngine: + assert QWebEnginePage is not None + member = getattr(QWebEnginePage, action, None) + base = QWebEnginePage.WebAction + + if not isinstance(member, base): raise cmdexc.CommandError("{} is not a valid web action!".format( action)) - view = self._current_widget() + for _ in range(count): - view.triggerPageAction(member) + # This whole command is backend-specific anyways, so it makes no + # sense to introduce some API for this. + # pylint: disable=protected-access + tab._widget.triggerPageAction(member) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_cmd_split=True) @@ -1864,26 +1698,27 @@ class CommandDispatcher: js_code: The string to evaluate. quiet: Don't show resulting JS object. """ - frame = self._current_widget().page().mainFrame() - out = frame.evaluateJavaScript(js_code) - if quiet: - return - - if out is None: - # Getting the actual error (if any) seems to be difficult. The - # error does end up in BrowserPage.javaScriptConsoleMessage(), but - # distinguishing between :jseval errors and errors from the webpage - # is not trivial... - message.info(self._win_id, 'No output or error') + jseval_cb = None else: - # The output can be a string, number, dict, array, etc. But *don't* - # output too much data, as this will make qutebrowser hang - out = str(out) - if len(out) > 5000: - message.info(self._win_id, out[:5000] + ' [...trimmed...]') - else: - message.info(self._win_id, out) + def jseval_cb(out): + if out is None: + # Getting the actual error (if any) seems to be difficult. + # The error does end up in + # BrowserPage.javaScriptConsoleMessage(), but + # distinguishing between :jseval errors and errors from the + # webpage is not trivial... + message.info(self._win_id, 'No output or error') + else: + # The output can be a string, number, dict, array, etc. But + # *don't* output too much data, as this will make + # qutebrowser hang + out = str(out) + if len(out) > 5000: + out = out[:5000] + ' [...trimmed...]' + message.info(self._win_id, out) + + self._current_widget().run_js_async(js_code, callback=jseval_cb) @cmdutils.register(instance='command-dispatcher', scope='window') def fake_key(self, keystring, global_=False): @@ -1914,10 +1749,12 @@ class CommandDispatcher: raise cmdexc.CommandError("No focused window!") else: try: - receiver = objreg.get('webview', scope='tab', - tab='current') + tab = objreg.get('tab', scope='tab', tab='current') except objreg.RegistryUnavailableError: raise cmdexc.CommandError("No focused webview!") + # pylint: disable=protected-access + receiver = tab._widget + # pylint: enable=protected-access QApplication.postEvent(receiver, press_event) QApplication.postEvent(receiver, release_event) @@ -1926,8 +1763,7 @@ class CommandDispatcher: debug=True) def debug_clear_ssl_errors(self): """Clear remembered SSL error answers.""" - nam = self._current_widget().page().networkAccessManager() - nam.clear_all_ssl_errors() + self._current_widget().clear_ssl_errors() @cmdutils.register(instance='command-dispatcher', scope='window') def edit_url(self, url=None, bg=False, tab=False, window=False): diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 4acdaad46..a04e0e1ee 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -84,6 +84,7 @@ class HintContext: args: Custom arguments for userscript/spawn rapid: Whether to do rapid hinting. mainframe: The main QWebFrame where we started hinting in. + tab: The WebTab object we started hinting in. group: The group of web elements to hint. """ @@ -98,6 +99,7 @@ class HintContext: self.destroyed_frames = [] self.args = [] self.mainframe = None + self.tab = None self.group = None def get_args(self, urlstr): @@ -569,7 +571,6 @@ class HintManager(QObject): """ cmd = context.args[0] args = context.args[1:] - frame = context.mainframe env = { 'QUTE_MODE': 'hints', 'QUTE_SELECTED_TEXT': str(elem), @@ -578,8 +579,12 @@ class HintManager(QObject): url = self._resolve_url(elem, context.baseurl) if url is not None: env['QUTE_URL'] = url.toString(QUrl.FullyEncoded) - env.update(userscripts.store_source(frame)) - userscripts.run(cmd, *args, win_id=self._win_id, env=env) + + try: + userscripts.run_async(context.tab, cmd, *args, win_id=self._win_id, + env=env) + except userscripts.UnsupportedError as e: + message.error(self._win_id, str(e), immediately=True) def _spawn(self, url, context): """Spawn a simple command from a hint. @@ -752,12 +757,13 @@ class HintManager(QObject): window=self._win_id) tabbed_browser.tabopen(url, background=background) else: - webview = objreg.get('webview', scope='tab', window=self._win_id, - tab=self._tab_id) - webview.openurl(url) + tab = objreg.get('tab', scope='tab', window=self._win_id, + tab=self._tab_id) + tab.openurl(url) @cmdutils.register(instance='hintmanager', scope='tab', name='hint', - star_args_optional=True, maxsplit=2) + star_args_optional=True, maxsplit=2, + backend=usertypes.Backend.QtWebKit) @cmdutils.argument('win_id', win_id=True) def start(self, rapid=False, group=webelem.Group.all, target=Target.normal, *args, win_id): @@ -811,10 +817,12 @@ class HintManager(QObject): """ tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) - widget = tabbed_browser.currentWidget() - if widget is None: + tab = tabbed_browser.currentWidget() + if tab is None: raise cmdexc.CommandError("No WebView available yet!") - mainframe = widget.page().mainFrame() + # FIXME:qtwebengine have a proper API for this + page = tab._widget.page() # pylint: disable=protected-access + mainframe = page.mainFrame() if mainframe is None: raise cmdexc.CommandError("No frame focused!") mode_manager = objreg.get('mode-manager', scope='window', @@ -837,6 +845,7 @@ class HintManager(QObject): self._check_args(target, *args) self._context = HintContext() + self._context.tab = tab self._context.target = target self._context.rapid = rapid try: diff --git a/qutebrowser/browser/webengine/__init__.py b/qutebrowser/browser/webengine/__init__.py new file mode 100644 index 000000000..d7c910b36 --- /dev/null +++ b/qutebrowser/browser/webengine/__init__.py @@ -0,0 +1,20 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2016 Florian Bruhin (The Compiler) +# +# 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 . + +"""Classes related to the browser widgets for QtWebEngine.""" diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py new file mode 100644 index 000000000..2d4ac4d89 --- /dev/null +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -0,0 +1,332 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# 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 . + +# FIXME:qtwebengine remove this once the stubs are gone +# pylint: disable=unused-variable + +"""Wrapper over a QWebEngineView.""" + +from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QPoint +from PyQt5.QtGui import QKeyEvent +from PyQt5.QtWidgets import QApplication +# pylint: disable=no-name-in-module,import-error,useless-suppression +from PyQt5.QtWebEngineWidgets import QWebEnginePage +# pylint: enable=no-name-in-module,import-error,useless-suppression + +from qutebrowser.browser import browsertab +from qutebrowser.browser.webengine import webview +from qutebrowser.utils import usertypes, qtutils, log + + +class WebEngineSearch(browsertab.AbstractSearch): + + """QtWebEngine implementations related to searching on the page.""" + + def search(self, text, *, ignore_case=False, wrap=False, reverse=False): + log.stub() + + def clear(self): + log.stub() + + def prev_result(self): + log.stub() + + def next_result(self): + log.stub() + + +class WebEngineCaret(browsertab.AbstractCaret): + + """QtWebEngine implementations related to moving the cursor/selection.""" + + @pyqtSlot(usertypes.KeyMode) + def _on_mode_entered(self, mode): + log.stub() + + @pyqtSlot(usertypes.KeyMode) + def _on_mode_left(self): + log.stub() + + def move_to_next_line(self, count=1): + log.stub() + + def move_to_prev_line(self, count=1): + log.stub() + + def move_to_next_char(self, count=1): + log.stub() + + def move_to_prev_char(self, count=1): + log.stub() + + def move_to_end_of_word(self, count=1): + log.stub() + + def move_to_next_word(self, count=1): + log.stub() + + def move_to_prev_word(self, count=1): + log.stub() + + def move_to_start_of_line(self): + log.stub() + + def move_to_end_of_line(self): + log.stub() + + def move_to_start_of_next_block(self, count=1): + log.stub() + + def move_to_start_of_prev_block(self, count=1): + log.stub() + + def move_to_end_of_next_block(self, count=1): + log.stub() + + def move_to_end_of_prev_block(self, count=1): + log.stub() + + def move_to_start_of_document(self): + log.stub() + + def move_to_end_of_document(self): + log.stub() + + def toggle_selection(self): + log.stub() + + def drop_selection(self): + log.stub() + + def has_selection(self): + return self._widget.hasSelection() + + def selection(self, html=False): + if html: + raise NotImplementedError + return self._widget.selectedText() + + def follow_selected(self, *, tab=False): + log.stub() + + +class WebEngineScroller(browsertab.AbstractScroller): + + """QtWebEngine implementations related to scrolling.""" + + def _key_press(self, key, count=1): + # FIXME:qtwebengine Abort scrolling if the minimum/maximum was reached. + press_evt = QKeyEvent(QEvent.KeyPress, key, Qt.NoModifier, 0, 0, 0) + release_evt = QKeyEvent(QEvent.KeyRelease, key, Qt.NoModifier, 0, 0, 0) + recipient = self._widget.focusProxy() + for _ in range(count): + # If we get a segfault here, we might want to try sendEvent + # instead. + QApplication.postEvent(recipient, press_evt) + QApplication.postEvent(recipient, release_evt) + + def pos_px(self): + log.stub() + return QPoint(0, 0) + + def pos_perc(self): + page = self._widget.page() + try: + size = page.contentsSize() + pos = page.scrollPosition() + except AttributeError: + # Added in Qt 5.7 + log.stub('on Qt < 5.7') + return (None, None) + else: + # FIXME:qtwebengine is this correct? + perc_x = 100 / size.width() * pos.x() + perc_y = 100 / size.height() * pos.y() + return (perc_x, perc_y) + + def to_perc(self, x=None, y=None): + log.stub() + + def to_point(self, point): + log.stub() + + def delta(self, x=0, y=0): + log.stub() + + def delta_page(self, x=0, y=0): + log.stub() + + def up(self, count=1): + self._key_press(Qt.Key_Up, count) + + def down(self, count=1): + self._key_press(Qt.Key_Down, count) + + def left(self, count=1): + self._key_press(Qt.Key_Left, count) + + def right(self, count=1): + self._key_press(Qt.Key_Right, count) + + def top(self): + self._key_press(Qt.Key_Home) + + def bottom(self): + self._key_press(Qt.Key_End) + + def page_up(self, count=1): + self._key_press(Qt.Key_PageUp, count) + + def page_down(self, count=1): + self._key_press(Qt.Key_PageDown, count) + + def at_top(self): + log.stub() + + def at_bottom(self): + log.stub() + + +class WebEngineHistory(browsertab.AbstractHistory): + + """QtWebEngine implementations related to page history.""" + + def current_idx(self): + return self._history.currentItemIndex() + + def back(self): + self._history.back() + + def forward(self): + self._history.forward() + + def can_go_back(self): + return self._history.canGoBack() + + def can_go_forward(self): + return self._history.canGoForward() + + def serialize(self): + return qtutils.serialize(self._history) + + def deserialize(self, data): + return qtutils.deserialize(data, self._history) + + def load_items(self, items): + log.stub() + + +class WebEngineZoom(browsertab.AbstractZoom): + + """QtWebEngine implementations related to zooming.""" + + def _set_factor_internal(self, factor): + self._widget.setZoomFactor(factor) + + def factor(self): + return self._widget.zoomFactor() + + +class WebEngineTab(browsertab.AbstractTab): + + """A QtWebEngine tab in the browser.""" + + def __init__(self, win_id, mode_manager, parent=None): + super().__init__(win_id) + widget = webview.WebEngineView() + self.history = WebEngineHistory(self) + self.scroll = WebEngineScroller() + self.caret = WebEngineCaret(win_id=win_id, mode_manager=mode_manager, + tab=self, parent=self) + self.zoom = WebEngineZoom(win_id=win_id, parent=self) + self.search = WebEngineSearch(parent=self) + self._set_widget(widget) + self._connect_signals() + self.backend = usertypes.Backend.QtWebEngine + + def openurl(self, url): + self._widget.load(url) + + def url(self): + return self._widget.url() + + def progress(self): + log.stub() + return 0 + + def load_status(self): + log.stub() + return usertypes.LoadStatus.success + + def dump_async(self, callback, *, plain=False): + if plain: + self._widget.page().toPlainText(callback) + else: + self._widget.page().toHtml(callback) + + def run_js_async(self, code, callback=None): + if callback is None: + self._widget.page().runJavaScript(code) + else: + self._widget.page().runJavaScript(code, callback) + + def shutdown(self): + log.stub() + + def reload(self, *, force=False): + if force: + action = QWebEnginePage.ReloadAndBypassCache + else: + action = QWebEnginePage.Reload + self._widget.triggerPageAction(action) + + def stop(self): + self._widget.stop() + + def title(self): + return self._widget.title() + + def icon(self): + return self._widget.icon() + + def set_html(self, html, base_url): + # FIXME:qtwebengine + # check this and raise an exception if too big: + # Warning: The content will be percent encoded before being sent to the + # renderer via IPC. This may increase its size. The maximum size of the + # percent encoded content is 2 megabytes minus 30 bytes. + self._widget.setHtml(html, base_url) + + def clear_ssl_errors(self): + log.stub() + + def _connect_signals(self): + view = self._widget + page = view.page() + page.windowCloseRequested.connect(self.window_close_requested) + page.linkHovered.connect(self.link_hovered) + page.loadProgress.connect(self.load_progress) + page.loadStarted.connect(self._on_load_started) + view.titleChanged.connect(self.title_changed) + page.loadFinished.connect(self.load_finished) + # FIXME:qtwebengine stub this? + # view.iconChanged.connect(self.icon_changed) + # view.scroll.pos_changed.connect(self.scroll.perc_changed) + # view.url_text_changed.connect(self.url_text_changed) + # view.load_status_changed.connect(self.load_status_changed) diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py new file mode 100644 index 000000000..cf7a14e97 --- /dev/null +++ b/qutebrowser/browser/webengine/webview.py @@ -0,0 +1,45 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2016 Florian Bruhin (The Compiler) +# +# 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 . + +"""The main browser widget for QtWebEngine.""" + + +from PyQt5.QtCore import pyqtSignal, Qt, QPoint +# pylint: disable=no-name-in-module,import-error,useless-suppression +from PyQt5.QtWebEngineWidgets import QWebEngineView +# pylint: enable=no-name-in-module,import-error,useless-suppression + + +class WebEngineView(QWebEngineView): + + """Custom QWebEngineView subclass with qutebrowser-specific features.""" + + mouse_wheel_zoom = pyqtSignal(QPoint) + + def wheelEvent(self, e): + """Zoom on Ctrl-Mousewheel. + + Args: + e: The QWheelEvent. + """ + if e.modifiers() & Qt.ControlModifier: + e.accept() + self.mouse_wheel_zoom.emit(e.angleDelta()) + else: + super().wheelEvent(e) diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index f7c1be119..a9b485e35 100644 --- a/qutebrowser/browser/webkit/mhtml.py +++ b/qutebrowser/browser/webkit/mhtml.py @@ -222,7 +222,7 @@ class _Downloader: """A class to download whole websites. Attributes: - web_view: The QWebView which contains the website that will be saved. + tab: The AbstractTab which contains the website that will be saved. dest: Destination filename. writer: The MHTMLWriter object which is used to save the page. loaded_urls: A set of QUrls of finished asset downloads. @@ -233,15 +233,15 @@ class _Downloader: _win_id: The window this downloader belongs to. """ - def __init__(self, web_view, dest): - self.web_view = web_view + def __init__(self, tab, dest): + self.tab = tab self.dest = dest self.writer = None - self.loaded_urls = {web_view.url()} + self.loaded_urls = {tab.url()} self.pending_downloads = set() self._finished_file = False self._used = False - self._win_id = web_view.win_id + self._win_id = tab.win_id def run(self): """Download and save the page. @@ -252,8 +252,11 @@ class _Downloader: if self._used: raise ValueError("Downloader already used") self._used = True - web_url = self.web_view.url() - web_frame = self.web_view.page().mainFrame() + web_url = self.tab.url() + + # FIXME:qtwebengine have a proper API for this + page = self.tab._widget.page() # pylint: disable=protected-access + web_frame = page.mainFrame() self.writer = MHTMLWriter( web_frame.toHtml().encode('utf-8'), @@ -479,28 +482,28 @@ class _NoCloseBytesIO(io.BytesIO): super().close() -def _start_download(dest, web_view): +def _start_download(dest, tab): """Start downloading the current page and all assets to an MHTML file. This will overwrite dest if it already exists. Args: dest: The filename where the resulting file should be saved. - web_view: Specify the webview whose page should be loaded. + tab: Specify the tab whose page should be loaded. """ - loader = _Downloader(web_view, dest) + loader = _Downloader(tab, dest) loader.run() -def start_download_checked(dest, web_view): +def start_download_checked(dest, tab): """First check if dest is already a file, then start the download. Args: dest: The filename where the resulting file should be saved. - web_view: Specify the webview whose page should be loaded. + tab: Specify the tab whose page should be loaded. """ # The default name is 'page title.mht' - title = web_view.title() + title = tab.title() default_name = utils.sanitize_filename(title + '.mht') # Remove characters which cannot be expressed in the file system encoding @@ -524,12 +527,12 @@ def start_download_checked(dest, web_view): # saving the file anyway. if not os.path.isdir(os.path.dirname(path)): folder = os.path.dirname(path) - message.error(web_view.win_id, + message.error(tab.win_id, "Directory {} does not exist.".format(folder)) return if not os.path.isfile(path): - _start_download(path, web_view=web_view) + _start_download(path, tab=tab) return q = usertypes.Question() @@ -537,7 +540,7 @@ def start_download_checked(dest, web_view): q.text = "{} exists. Overwrite?".format(path) q.completed.connect(q.deleteLater) q.answered_yes.connect(functools.partial( - _start_download, path, web_view=web_view)) + _start_download, path, tab=tab)) message_bridge = objreg.get('message-bridge', scope='window', - window=web_view.win_id) + window=tab.win_id) message_bridge.ask(q, blocking=False) diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index bdd98efc1..e2938d904 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -228,9 +228,9 @@ class NetworkManager(QNetworkAccessManager): # This might be a generic network manager, e.g. one belonging to a # DownloadManager. In this case, just skip the webview thing. if self._tab_id is not None: - webview = objreg.get('webview', scope='tab', window=self._win_id, - tab=self._tab_id) - webview.loadStarted.connect(q.abort) + tab = objreg.get('tab', scope='tab', window=self._win_id, + tab=self._tab_id) + tab.load_started.connect(q.abort) bridge = objreg.get('message-bridge', scope='window', window=self._win_id) bridge.ask(q, blocking=True) @@ -479,9 +479,9 @@ class NetworkManager(QNetworkAccessManager): if self._tab_id is not None: try: - webview = objreg.get('webview', scope='tab', - window=self._win_id, tab=self._tab_id) - current_url = webview.url() + tab = objreg.get('tab', scope='tab', window=self._win_id, + tab=self._tab_id) + current_url = tab.url() except (KeyError, RuntimeError, TypeError): # https://github.com/The-Compiler/qutebrowser/issues/889 # Catching RuntimeError and TypeError because we could be in diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py new file mode 100644 index 000000000..aa6b5a4b7 --- /dev/null +++ b/qutebrowser/browser/webkit/webkittab.py @@ -0,0 +1,543 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# 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 . + +"""Wrapper over our (QtWebKit) WebView.""" + +import sys +import functools +import xml.etree.ElementTree + +from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QUrl, QPoint, QTimer +from PyQt5.QtGui import QKeyEvent +from PyQt5.QtWebKitWidgets import QWebPage +from PyQt5.QtWebKit import QWebSettings + +from qutebrowser.browser import browsertab +from qutebrowser.browser.webkit import webview, tabhistory +from qutebrowser.utils import qtutils, objreg, usertypes, utils + + +class WebKitSearch(browsertab.AbstractSearch): + + """QtWebKit implementations related to searching on the page.""" + + def clear(self): + # We first clear the marked text, then the highlights + self._widget.search('', 0) + self._widget.search('', QWebPage.HighlightAllOccurrences) + + def search(self, text, *, ignore_case=False, wrap=False, reverse=False): + flags = 0 + if ignore_case == 'smart': + if not text.islower(): + flags |= QWebPage.FindCaseSensitively + elif not ignore_case: + flags |= QWebPage.FindCaseSensitively + if wrap: + flags |= QWebPage.FindWrapsAroundDocument + if reverse: + flags |= QWebPage.FindBackward + # We actually search *twice* - once to highlight everything, then again + # to get a mark so we can navigate. + self._widget.search(text, flags) + self._widget.search(text, flags | QWebPage.HighlightAllOccurrences) + self.text = text + self._flags = flags + + def next_result(self): + self._widget.search(self.text, self._flags) + + def prev_result(self): + # The int() here serves as a QFlags constructor to create a copy of the + # QFlags instance rather as a reference. I don't know why it works this + # way, but it does. + flags = int(self._flags) + if flags & QWebPage.FindBackward: + flags &= ~QWebPage.FindBackward + else: + flags |= QWebPage.FindBackward + self._widget.search(self.text, flags) + + +class WebKitCaret(browsertab.AbstractCaret): + + """QtWebKit implementations related to moving the cursor/selection.""" + + @pyqtSlot(usertypes.KeyMode) + def _on_mode_entered(self, mode): + if mode != usertypes.KeyMode.caret: + return + + settings = self._widget.settings() + settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True) + self.selection_enabled = bool(self.selection()) + + if self._widget.isVisible(): + # Sometimes the caret isn't immediately visible, but unfocusing + # and refocusing it fixes that. + self._widget.clearFocus() + self._widget.setFocus(Qt.OtherFocusReason) + + # Move the caret to the first element in the viewport if there + # isn't any text which is already selected. + # + # Note: We can't use hasSelection() here, as that's always + # true in caret mode. + if not self.selection(): + self._widget.page().currentFrame().evaluateJavaScript( + utils.read_file('javascript/position_caret.js')) + + @pyqtSlot() + def _on_mode_left(self): + settings = self._widget.settings() + if settings.testAttribute(QWebSettings.CaretBrowsingEnabled): + if self.selection_enabled and self._widget.hasSelection(): + # Remove selection if it exists + self._widget.triggerPageAction(QWebPage.MoveToNextChar) + settings.setAttribute(QWebSettings.CaretBrowsingEnabled, False) + self.selection_enabled = False + + def move_to_next_line(self, count=1): + if not self.selection_enabled: + act = QWebPage.MoveToNextLine + else: + act = QWebPage.SelectNextLine + for _ in range(count): + self._widget.triggerPageAction(act) + + def move_to_prev_line(self, count=1): + if not self.selection_enabled: + act = QWebPage.MoveToPreviousLine + else: + act = QWebPage.SelectPreviousLine + for _ in range(count): + self._widget.triggerPageAction(act) + + def move_to_next_char(self, count=1): + if not self.selection_enabled: + act = QWebPage.MoveToNextChar + else: + act = QWebPage.SelectNextChar + for _ in range(count): + self._widget.triggerPageAction(act) + + def move_to_prev_char(self, count=1): + if not self.selection_enabled: + act = QWebPage.MoveToPreviousChar + else: + act = QWebPage.SelectPreviousChar + for _ in range(count): + self._widget.triggerPageAction(act) + + def move_to_end_of_word(self, count=1): + if not self.selection_enabled: + act = [QWebPage.MoveToNextWord] + if sys.platform == 'win32': # pragma: no cover + act.append(QWebPage.MoveToPreviousChar) + else: + act = [QWebPage.SelectNextWord] + if sys.platform == 'win32': # pragma: no cover + act.append(QWebPage.SelectPreviousChar) + for _ in range(count): + for a in act: + self._widget.triggerPageAction(a) + + def move_to_next_word(self, count=1): + if not self.selection_enabled: + act = [QWebPage.MoveToNextWord] + if sys.platform != 'win32': # pragma: no branch + act.append(QWebPage.MoveToNextChar) + else: + act = [QWebPage.SelectNextWord] + if sys.platform != 'win32': # pragma: no branch + act.append(QWebPage.SelectNextChar) + for _ in range(count): + for a in act: + self._widget.triggerPageAction(a) + + def move_to_prev_word(self, count=1): + if not self.selection_enabled: + act = QWebPage.MoveToPreviousWord + else: + act = QWebPage.SelectPreviousWord + for _ in range(count): + self._widget.triggerPageAction(act) + + def move_to_start_of_line(self): + if not self.selection_enabled: + act = QWebPage.MoveToStartOfLine + else: + act = QWebPage.SelectStartOfLine + self._widget.triggerPageAction(act) + + def move_to_end_of_line(self): + if not self.selection_enabled: + act = QWebPage.MoveToEndOfLine + else: + act = QWebPage.SelectEndOfLine + self._widget.triggerPageAction(act) + + def move_to_start_of_next_block(self, count=1): + if not self.selection_enabled: + act = [QWebPage.MoveToNextLine, + QWebPage.MoveToStartOfBlock] + else: + act = [QWebPage.SelectNextLine, + QWebPage.SelectStartOfBlock] + for _ in range(count): + for a in act: + self._widget.triggerPageAction(a) + + def move_to_start_of_prev_block(self, count=1): + if not self.selection_enabled: + act = [QWebPage.MoveToPreviousLine, + QWebPage.MoveToStartOfBlock] + else: + act = [QWebPage.SelectPreviousLine, + QWebPage.SelectStartOfBlock] + for _ in range(count): + for a in act: + self._widget.triggerPageAction(a) + + def move_to_end_of_next_block(self, count=1): + if not self.selection_enabled: + act = [QWebPage.MoveToNextLine, + QWebPage.MoveToEndOfBlock] + else: + act = [QWebPage.SelectNextLine, + QWebPage.SelectEndOfBlock] + for _ in range(count): + for a in act: + self._widget.triggerPageAction(a) + + def move_to_end_of_prev_block(self, count=1): + if not self.selection_enabled: + act = [QWebPage.MoveToPreviousLine, QWebPage.MoveToEndOfBlock] + else: + act = [QWebPage.SelectPreviousLine, QWebPage.SelectEndOfBlock] + for _ in range(count): + for a in act: + self._widget.triggerPageAction(a) + + def move_to_start_of_document(self): + if not self.selection_enabled: + act = QWebPage.MoveToStartOfDocument + else: + act = QWebPage.SelectStartOfDocument + self._widget.triggerPageAction(act) + + def move_to_end_of_document(self): + if not self.selection_enabled: + act = QWebPage.MoveToEndOfDocument + else: + act = QWebPage.SelectEndOfDocument + self._widget.triggerPageAction(act) + + def toggle_selection(self): + self.selection_enabled = not self.selection_enabled + mainwindow = objreg.get('main-window', scope='window', + window=self._win_id) + mainwindow.status.set_mode_active(usertypes.KeyMode.caret, True) + + def drop_selection(self): + self._widget.triggerPageAction(QWebPage.MoveToNextChar) + + def has_selection(self): + return self._widget.hasSelection() + + def selection(self, html=False): + if html: + return self._widget.selectedHtml() + return self._widget.selectedText() + + def follow_selected(self, *, tab=False): + if not self.has_selection(): + return + if QWebSettings.globalSettings().testAttribute( + QWebSettings.JavascriptEnabled): + if tab: + self._widget.page().open_target = usertypes.ClickTarget.tab + self._tab.run_js_async( + 'window.getSelection().anchorNode.parentNode.click()') + else: + selection = self.selection(html=True) + try: + selected_element = xml.etree.ElementTree.fromstring( + '{}'.format(selection)).find('a') + except xml.etree.ElementTree.ParseError: + raise browsertab.WebTabError('Could not parse selected ' + 'element!') + + if selected_element is not None: + try: + url = selected_element.attrib['href'] + except KeyError: + raise browsertab.WebTabError('Anchor element without ' + 'href!') + url = self._tab.url().resolved(QUrl(url)) + if tab: + self._tab.new_tab_requested.emit(url) + else: + self._tab.openurl(url) + + +class WebKitZoom(browsertab.AbstractZoom): + + """QtWebKit implementations related to zooming.""" + + def _set_factor_internal(self, factor): + self._widget.setZoomFactor(factor) + + def factor(self): + return self._widget.zoomFactor() + + +class WebKitScroller(browsertab.AbstractScroller): + + """QtWebKit implementations related to scrolling.""" + + # FIXME:qtwebengine When to use the main frame, when the current one? + + def pos_px(self): + return self._widget.page().mainFrame().scrollPosition() + + def pos_perc(self): + return self._widget.scroll_pos + + def to_point(self, point): + self._widget.page().mainFrame().setScrollPosition(point) + + def delta(self, x=0, y=0): + qtutils.check_overflow(x, 'int') + qtutils.check_overflow(y, 'int') + self._widget.page().mainFrame().scroll(x, y) + + def delta_page(self, x=0.0, y=0.0): + if y.is_integer(): + y = int(y) + if y == 0: + pass + elif y < 0: + self.page_up(count=-y) + elif y > 0: + self.page_down(count=y) + y = 0 + if x == 0 and y == 0: + return + size = self._widget.page().mainFrame().geometry() + self.delta(x * size.width(), y * size.height()) + + def to_perc(self, x=None, y=None): + if x is None and y == 0: + self.top() + elif x is None and y == 100: + self.bottom() + else: + for val, orientation in [(x, Qt.Horizontal), (y, Qt.Vertical)]: + if val is not None: + val = qtutils.check_overflow(val, 'int', fatal=False) + frame = self._widget.page().mainFrame() + m = frame.scrollBarMaximum(orientation) + if m == 0: + continue + frame.setScrollBarValue(orientation, int(m * val / 100)) + + def _key_press(self, key, count=1, getter_name=None, direction=None): + frame = self._widget.page().mainFrame() + press_evt = QKeyEvent(QEvent.KeyPress, key, Qt.NoModifier, 0, 0, 0) + release_evt = QKeyEvent(QEvent.KeyRelease, key, Qt.NoModifier, 0, 0, 0) + getter = None if getter_name is None else getattr(frame, getter_name) + + # FIXME:qtwebengine needed? + # self._widget.setFocus() + + for _ in range(count): + # Abort scrolling if the minimum/maximum was reached. + if (getter is not None and + frame.scrollBarValue(direction) == getter(direction)): + return + self._widget.keyPressEvent(press_evt) + self._widget.keyReleaseEvent(release_evt) + + def up(self, count=1): + self._key_press(Qt.Key_Up, count, 'scrollBarMinimum', Qt.Vertical) + + def down(self, count=1): + self._key_press(Qt.Key_Down, count, 'scrollBarMaximum', Qt.Vertical) + + def left(self, count=1): + self._key_press(Qt.Key_Left, count, 'scrollBarMinimum', Qt.Horizontal) + + def right(self, count=1): + self._key_press(Qt.Key_Right, count, 'scrollBarMaximum', Qt.Horizontal) + + def top(self): + self._key_press(Qt.Key_Home) + + def bottom(self): + self._key_press(Qt.Key_End) + + def page_up(self, count=1): + self._key_press(Qt.Key_PageUp, count, 'scrollBarMinimum', Qt.Vertical) + + def page_down(self, count=1): + self._key_press(Qt.Key_PageDown, count, 'scrollBarMaximum', + Qt.Vertical) + + def at_top(self): + return self.pos_px().y() == 0 + + def at_bottom(self): + frame = self._widget.page().currentFrame() + return self.pos_px().y() >= frame.scrollBarMaximum(Qt.Vertical) + + +class WebKitHistory(browsertab.AbstractHistory): + + """QtWebKit implementations related to page history.""" + + def current_idx(self): + return self._history.currentItemIndex() + + def back(self): + self._history.back() + + def forward(self): + self._history.forward() + + def can_go_back(self): + return self._history.canGoBack() + + def can_go_forward(self): + return self._history.canGoForward() + + def serialize(self): + return qtutils.serialize(self._history) + + def deserialize(self, data): + return qtutils.deserialize(data, self._history) + + def load_items(self, items): + stream, _data, user_data = tabhistory.serialize(items) + qtutils.deserialize_stream(stream, self._history) + for i, data in enumerate(user_data): + self._history.itemAt(i).setUserData(data) + cur_data = self._history.currentItem().userData() + if cur_data is not None: + if 'zoom' in cur_data: + self._tab.zoom.set_factor(cur_data['zoom']) + if ('scroll-pos' in cur_data and + self._tab.scroll.pos_px() == QPoint(0, 0)): + QTimer.singleShot(0, functools.partial( + self._tab.scroll.to_point, cur_data['scroll-pos'])) + + +class WebKitTab(browsertab.AbstractTab): + + """A QtWebKit tab in the browser.""" + + def __init__(self, win_id, mode_manager, parent=None): + super().__init__(win_id) + widget = webview.WebView(win_id, self.tab_id, tab=self) + self.history = WebKitHistory(self) + self.scroll = WebKitScroller(parent=self) + self.caret = WebKitCaret(win_id=win_id, mode_manager=mode_manager, + tab=self, parent=self) + self.zoom = WebKitZoom(win_id=win_id, parent=self) + self.search = WebKitSearch(parent=self) + self._set_widget(widget) + self._connect_signals() + self.zoom.set_default() + self.backend = usertypes.Backend.QtWebKit + + def openurl(self, url): + self._widget.openurl(url) + + def url(self): + return self._widget.cur_url + + def progress(self): + return self._widget.progress + + def load_status(self): + return self._widget.load_status + + def dump_async(self, callback, *, plain=False): + frame = self._widget.page().mainFrame() + if plain: + callback(frame.toPlainText()) + else: + callback(frame.toHtml()) + + def run_js_async(self, code, callback=None): + result = self._widget.page().mainFrame().evaluateJavaScript(code) + if callback is not None: + callback(result) + + def icon(self): + return self._widget.icon() + + def shutdown(self): + self._widget.shutdown() + + def reload(self, *, force=False): + if force: + action = QWebPage.ReloadAndBypassCache + else: + action = QWebPage.Reload + self._widget.triggerPageAction(action) + + def stop(self): + self._widget.stop() + + def title(self): + return self._widget.title() + + def clear_ssl_errors(self): + nam = self._widget.page().networkAccessManager() + nam.clear_all_ssl_errors() + + def set_html(self, html, base_url): + self._widget.setHtml(html, base_url) + + def _connect_signals(self): + view = self._widget + page = view.page() + frame = page.mainFrame() + page.windowCloseRequested.connect(self.window_close_requested) + page.linkHovered.connect(self.link_hovered) + page.loadProgress.connect(self.load_progress) + frame.loadStarted.connect(self._on_load_started) + view.scroll_pos_changed.connect(self.scroll.perc_changed) + view.titleChanged.connect(self.title_changed) + view.url_text_changed.connect(self.url_text_changed) + view.load_status_changed.connect(self.load_status_changed) + view.shutting_down.connect(self.shutting_down) + + # Make sure we emit an appropriate status when loading finished. While + # Qt has a bool "ok" attribute for loadFinished, it always is True when + # using error pages... + # See https://github.com/The-Compiler/qutebrowser/issues/84 + frame.loadFinished.connect(lambda: + self.load_finished.emit( + not self._widget.page().error_occurred)) + + # Emit iconChanged with a QIcon like QWebEngineView does. + view.iconChanged.connect(lambda: + self.icon_changed.emit(self._widget.icon())) diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 90e96f63b..3834ab1ba 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -21,8 +21,7 @@ import functools -from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl, QPoint, - QTimer) +from PyQt5.QtCore import pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl, QPoint from PyQt5.QtGui import QDesktopServices from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from PyQt5.QtWidgets import QFileDialog @@ -31,7 +30,7 @@ from PyQt5.QtWebKitWidgets import QWebPage from qutebrowser.config import config from qutebrowser.browser import pdfjs -from qutebrowser.browser.webkit import http, tabhistory +from qutebrowser.browser.webkit import http from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.utils import (message, usertypes, log, jinja, qtutils, utils, objreg, debug, urlutils) @@ -243,23 +242,6 @@ class BrowserPage(QWebPage): else: nam.shutdown() - def load_history(self, entries): - """Load the history from a list of TabHistoryItem objects.""" - stream, _data, user_data = tabhistory.serialize(entries) - history = self.history() - qtutils.deserialize_stream(stream, history) - for i, data in enumerate(user_data): - history.itemAt(i).setUserData(data) - cur_data = history.currentItem().userData() - if cur_data is not None: - frame = self.mainFrame() - if 'zoom' in cur_data: - frame.page().view().zoom_perc(cur_data['zoom'] * 100) - if ('scroll-pos' in cur_data and - frame.scrollPosition() == QPoint(0, 0)): - QTimer.singleShot(0, functools.partial( - frame.setScrollPosition, cur_data['scroll-pos'])) - def display_content(self, reply, mimetype): """Display a QNetworkReply with an explicitly set mimetype.""" self.mainFrame().setContent(reply.readAll(), mimetype, reply.url()) @@ -436,7 +418,7 @@ class BrowserPage(QWebPage): if data is None: return if 'zoom' in data: - frame.page().view().zoom_perc(data['zoom'] * 100) + frame.page().view().tab.zoom.set_factor(data['zoom']) if 'scroll-pos' in data and frame.scrollPosition() == QPoint(0, 0): frame.setScrollPosition(data['scroll-pos']) diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py index d8a07aa84..845c40f3f 100644 --- a/qutebrowser/browser/webkit/webview.py +++ b/qutebrowser/browser/webkit/webview.py @@ -20,10 +20,8 @@ """The main browser widgets.""" import sys -import itertools -import functools -from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QTimer, QUrl +from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QTimer, QUrl, QPoint from PyQt5.QtGui import QPalette from PyQt5.QtWidgets import QApplication, QStyleFactory from PyQt5.QtWebKit import QWebSettings @@ -36,41 +34,23 @@ from qutebrowser.browser import hints from qutebrowser.browser.webkit import webpage, webelem -LoadStatus = usertypes.enum('LoadStatus', ['none', 'success', 'success_https', - 'error', 'warn', 'loading']) - - -tab_id_gen = itertools.count(0) - - class WebView(QWebView): - """One browser tab in TabbedBrowser. - - Our own subclass of a QWebView with some added bells and whistles. + """Custom QWebView subclass with qutebrowser-specific features. Attributes: + tab: The WebKitTab object for this WebView hintmanager: The HintManager instance for this view. progress: loading progress of this page. scroll_pos: The current scroll position as (x%, y%) tuple. statusbar_message: The current javascript statusbar message. - inspector: The QWebInspector used for this webview. load_status: loading status of this page (index into LoadStatus) - viewing_source: Whether the webview is currently displaying source - code. - keep_icon: Whether the (e.g. cloned) icon should not be cleared on page - load. - registry: The ObjectRegistry associated with this tab. - tab_id: The tab ID of the view. win_id: The window ID of the view. - search_text: The text of the last search. - search_flags: The search flags of the last search. + _tab_id: The tab ID of the view. _has_ssl_errors: Whether SSL errors occurred during loading. - _zoom: A NeighborList with the zoom levels. _old_scroll_pos: The old scroll position. _check_insertmode: If True, in mouseReleaseEvent we should check if we need to enter/leave insert mode. - _default_zoom_changed: Whether the zoom was changed from the default. _ignore_wheel_event: Ignore the next wheel event. See https://github.com/The-Compiler/qutebrowser/issues/395 @@ -81,6 +61,9 @@ class WebView(QWebView): linkHovered: QWebPages linkHovered signal exposed. load_status_changed: The loading status changed url_text_changed: Current URL string changed. + mouse_wheel_zoom: Emitted when the page should be zoomed because the + mousewheel was used with ctrl. + arg 1: The angle delta of the wheel event (QPoint) shutting_down: Emitted when the view is shutting down. """ @@ -89,57 +72,39 @@ class WebView(QWebView): load_status_changed = pyqtSignal(str) url_text_changed = pyqtSignal(str) shutting_down = pyqtSignal() + mouse_wheel_zoom = pyqtSignal(QPoint) - def __init__(self, win_id, parent=None): + def __init__(self, win_id, tab_id, tab, parent=None): super().__init__(parent) if sys.platform == 'darwin' and qtutils.version_check('5.4'): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-42948 # See https://github.com/The-Compiler/qutebrowser/issues/462 self.setStyle(QStyleFactory.create('Fusion')) + self.tab = tab self.win_id = win_id - self.load_status = LoadStatus.none + self.load_status = usertypes.LoadStatus.none self._check_insertmode = False - self.inspector = None self.scroll_pos = (-1, -1) self.statusbar_message = '' self._old_scroll_pos = (-1, -1) - self._zoom = None self._has_ssl_errors = False self._ignore_wheel_event = False - self.keep_icon = False - self.search_text = None - self.search_flags = 0 - self.selection_enabled = False - self.init_neighborlist() self._set_bg_color() - cfg = objreg.get('config') - cfg.changed.connect(self.init_neighborlist) - # For some reason, this signal doesn't get disconnected automatically - # when the WebView is destroyed on older PyQt versions. - # See https://github.com/The-Compiler/qutebrowser/issues/390 - self.destroyed.connect(functools.partial( - cfg.changed.disconnect, self.init_neighborlist)) self.cur_url = QUrl() self.progress = 0 - self.registry = objreg.ObjectRegistry() - self.tab_id = next(tab_id_gen) - tab_registry = objreg.get('tab-registry', scope='window', - window=win_id) - tab_registry[self.tab_id] = self - objreg.register('webview', self, registry=self.registry) + self._tab_id = tab_id + page = self._init_page() - hintmanager = hints.HintManager(win_id, self.tab_id, self) + hintmanager = hints.HintManager(win_id, self._tab_id, self) hintmanager.mouse_event.connect(self.on_mouse_event) hintmanager.start_hinting.connect(page.on_start_hinting) hintmanager.stop_hinting.connect(page.on_stop_hinting) - objreg.register('hintmanager', hintmanager, registry=self.registry) + objreg.register('hintmanager', hintmanager, scope='tab', window=win_id, + tab=tab_id) mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.entered.connect(self.on_mode_entered) mode_manager.left.connect(self.on_mode_left) - self.viewing_source = False - self.setZoomFactor(float(config.get('ui', 'default-zoom')) / 100) - self._default_zoom_changed = False if config.get('input', 'rocker-gestures'): self.setContextMenuPolicy(Qt.PreventContextMenu) self.urlChanged.connect(self.on_url_changed) @@ -161,7 +126,7 @@ class WebView(QWebView): def _init_page(self): """Initialize the QWebPage used by this view.""" - page = webpage.BrowserPage(self.win_id, self.tab_id, self) + page = webpage.BrowserPage(self.win_id, self._tab_id, self) self.setPage(page) page.linkHovered.connect(self.linkHovered) page.mainFrame().loadStarted.connect(self.on_load_started) @@ -176,7 +141,7 @@ class WebView(QWebView): def __repr__(self): url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), 100) - return utils.get_repr(self, tab_id=self.tab_id, url=url) + return utils.get_repr(self, tab_id=self._tab_id, url=url) def __del__(self): # Explicitly releasing the page here seems to prevent some segfaults @@ -193,7 +158,7 @@ class WebView(QWebView): def _set_load_status(self, val): """Setter for load_status.""" - if not isinstance(val, LoadStatus): + if not isinstance(val, usertypes.LoadStatus): raise TypeError("Type {} is no LoadStatus member!".format(val)) log.webview.debug("load status for {}: {}".format(repr(self), val)) self.load_status = val @@ -210,14 +175,8 @@ class WebView(QWebView): @pyqtSlot(str, str) def on_config_changed(self, section, option): - """Reinitialize the zoom neighborlist if related config changed.""" - if section == 'ui' and option in ('zoom-levels', 'default-zoom'): - if not self._default_zoom_changed: - self.setZoomFactor(float(config.get('ui', 'default-zoom')) / - 100) - self._default_zoom_changed = False - self.init_neighborlist() - elif section == 'input' and option == 'rocker-gestures': + """Update rocker gestures/background color.""" + if section == 'input' and option == 'rocker-gestures': if config.get('input', 'rocker-gestures'): self.setContextMenuPolicy(Qt.PreventContextMenu) else: @@ -225,13 +184,6 @@ class WebView(QWebView): elif section == 'colors' and option == 'webpage.bg': self._set_bg_color() - def init_neighborlist(self): - """Initialize the _zoom neighborlist.""" - levels = config.get('ui', 'zoom-levels') - self._zoom = usertypes.NeighborList( - levels, mode=usertypes.NeighborList.Modes.edge) - self._zoom.fuzzyval = config.get('ui', 'default-zoom') - def _mousepress_backforward(self, e): """Handle back/forward mouse button presses. @@ -381,33 +333,6 @@ class WebView(QWebView): bridge = objreg.get('js-bridge') frame.addToJavaScriptWindowObject('qute', bridge) - def zoom_perc(self, perc, fuzzyval=True): - """Zoom to a given zoom percentage. - - Args: - perc: The zoom percentage as int. - fuzzyval: Whether to set the NeighborLists fuzzyval. - """ - if fuzzyval: - self._zoom.fuzzyval = int(perc) - if perc < 0: - raise ValueError("Can't zoom {}%!".format(perc)) - self.setZoomFactor(float(perc) / 100) - self._default_zoom_changed = True - - def zoom(self, offset): - """Increase/Decrease the zoom level. - - Args: - offset: The offset in the zoom level list. - - Return: - The new zoom percentage. - """ - level = self._zoom.getitem(offset) - self.zoom_perc(level, fuzzyval=False) - return level - @pyqtSlot('QUrl') def on_url_changed(self, url): """Update cur_url when URL has changed. @@ -431,9 +356,8 @@ class WebView(QWebView): def on_load_started(self): """Leave insert/hint mode and set vars when a new page is loading.""" self.progress = 0 - self.viewing_source = False self._has_ssl_errors = False - self._set_load_status(LoadStatus.loading) + self._set_load_status(usertypes.LoadStatus.loading) @pyqtSlot() def on_load_finished(self): @@ -446,14 +370,14 @@ class WebView(QWebView): ok = not self.page().error_occurred if ok and not self._has_ssl_errors: if self.cur_url.scheme() == 'https': - self._set_load_status(LoadStatus.success_https) + self._set_load_status(usertypes.LoadStatus.success_https) else: - self._set_load_status(LoadStatus.success) + self._set_load_status(usertypes.LoadStatus.success) elif ok: - self._set_load_status(LoadStatus.warn) + self._set_load_status(usertypes.LoadStatus.warn) else: - self._set_load_status(LoadStatus.error) + self._set_load_status(usertypes.LoadStatus.error) if not self.title(): self.titleChanged.emit(self.url().toDisplayString()) self._handle_auto_insert_mode(ok) @@ -486,25 +410,6 @@ class WebView(QWebView): log.webview.debug("Ignoring focus because mode {} was " "entered.".format(mode)) self.setFocusPolicy(Qt.NoFocus) - elif mode == usertypes.KeyMode.caret: - settings = self.settings() - settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True) - self.selection_enabled = bool(self.page().selectedText()) - - if self.isVisible(): - # Sometimes the caret isn't immediately visible, but unfocusing - # and refocusing it fixes that. - self.clearFocus() - self.setFocus(Qt.OtherFocusReason) - - # Move the caret to the first element in the viewport if there - # isn't any text which is already selected. - # - # Note: We can't use hasSelection() here, as that's always - # true in caret mode. - if not self.page().selectedText(): - self.page().currentFrame().evaluateJavaScript( - utils.read_file('javascript/position_caret.js')) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): @@ -513,15 +418,6 @@ class WebView(QWebView): usertypes.KeyMode.yesno): log.webview.debug("Restoring focus policy because mode {} was " "left.".format(mode)) - elif mode == usertypes.KeyMode.caret: - settings = self.settings() - if settings.testAttribute(QWebSettings.CaretBrowsingEnabled): - if self.selection_enabled and self.hasSelection(): - # Remove selection if it exists - self.triggerPageAction(QWebPage.MoveToNextChar) - settings.setAttribute(QWebSettings.CaretBrowsingEnabled, False) - self.selection_enabled = False - self.setFocusPolicy(Qt.WheelFocus) def search(self, text, flags): @@ -590,7 +486,8 @@ class WebView(QWebView): "support that!") tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self.win_id) - return tabbed_browser.tabopen(background=False) + # pylint: disable=protected-access + return tabbed_browser.tabopen(background=False)._widget def paintEvent(self, e): """Extend paintEvent to emit a signal if the scroll position changed. @@ -672,14 +569,6 @@ class WebView(QWebView): return if e.modifiers() & Qt.ControlModifier: e.accept() - divider = config.get('input', 'mouse-zoom-divider') - factor = self.zoomFactor() + e.angleDelta().y() / divider - if factor < 0: - return - perc = int(100 * factor) - message.info(self.win_id, "Zoom level: {}%".format(perc)) - self._zoom.fuzzyval = perc - self.setZoomFactor(factor) - self._default_zoom_changed = True + self.mouse_wheel_zoom.emit(e.angleDelta()) else: super().wheelEvent(e) diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 41d97481c..35ba2fbfe 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -80,6 +80,8 @@ class Command: parser: The ArgumentParser to use to parse this command. flags_with_args: A list of flags which take an argument. no_cmd_split: If true, ';;' to split sub-commands is ignored. + backend: Which backend the command works with (or None if it works with + both) _qute_args: The saved data from @cmdutils.argument _needs_js: Whether the command needs javascript enabled _modes: The modes the command can be executed in. @@ -92,7 +94,8 @@ class Command: def __init__(self, *, handler, name, instance=None, maxsplit=None, hide=False, modes=None, not_modes=None, needs_js=False, debug=False, ignore_args=False, deprecated=False, - no_cmd_split=False, star_args_optional=False, scope='global'): + no_cmd_split=False, star_args_optional=False, scope='global', + backend=None): # I really don't know how to solve this in a better way, I tried. # pylint: disable=too-many-locals if modes is not None and not_modes is not None: @@ -123,6 +126,8 @@ class Command: self.ignore_args = ignore_args self.handler = handler self.no_cmd_split = no_cmd_split + self.backend = backend + self.docparser = docutils.DocstringParser(handler) self.parser = argparser.ArgumentParser( name, description=self.docparser.short_desc, @@ -170,10 +175,22 @@ class Command: raise cmdexc.PrerequisitesError( "{}: This command is not allowed in {} mode.".format( self.name, mode_names)) + if self._needs_js and not QWebSettings.globalSettings().testAttribute( QWebSettings.JavascriptEnabled): raise cmdexc.PrerequisitesError( "{}: This command needs javascript enabled.".format(self.name)) + + backend_mapping = { + 'webkit': usertypes.Backend.QtWebKit, + 'webengine': usertypes.Backend.QtWebEngine, + } + used_backend = backend_mapping[objreg.get('args').backend] + if self.backend is not None and used_backend != self.backend: + raise cmdexc.PrerequisitesError( + "{}: Only available with {} " + "backend.".format(self.name, self.backend.name)) + if self.deprecated: message.warning(win_id, '{} is deprecated - {}'.format( self.name, self.deprecated)) @@ -483,6 +500,9 @@ class Command: dbgout = ["command called:", self.name] if args: dbgout.append(str(args)) + elif args is None: + args = [] + if count is not None: dbgout.append("(count={})".format(count)) log.commands.debug(' '.join(dbgout)) @@ -497,8 +517,8 @@ class Command: e.status, e)) return self._count = count - posargs, kwargs = self._get_call_args(win_id) self._check_prerequisites(win_id) + posargs, kwargs = self._get_call_args(win_id) log.commands.debug('Calling {}'.format( debug_utils.format_call(self.handler, posargs, kwargs))) self.handler(*posargs, **kwargs) diff --git a/qutebrowser/commands/userscripts.py b/qutebrowser/commands/userscripts.py index a8e62cf39..0a8b19524 100644 --- a/qutebrowser/commands/userscripts.py +++ b/qutebrowser/commands/userscripts.py @@ -26,7 +26,7 @@ import tempfile from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QSocketNotifier from qutebrowser.utils import message, log, objreg, standarddir -from qutebrowser.commands import runners, cmdexc +from qutebrowser.commands import runners from qutebrowser.config import config from qutebrowser.misc import guiprocess from qutebrowser.browser.webkit import downloads @@ -86,6 +86,10 @@ class _BaseUserscriptRunner(QObject): _proc: The GUIProcess which is being executed. _win_id: The window ID this runner is associated with. _cleaned_up: Whether temporary files were cleaned up. + _text_stored: Set when the page text was stored async. + _html_stored: Set when the page html was stored async. + _args: Arguments to pass to _run_process. + _kwargs: Keyword arguments to pass to _run_process. Signals: got_cmd: Emitted when a new command arrived and should be executed. @@ -101,9 +105,41 @@ class _BaseUserscriptRunner(QObject): self._win_id = win_id self._filepath = None self._proc = None - self._env = None + self._env = {} + self._text_stored = False + self._html_stored = False + self._args = None + self._kwargs = None - def _run_process(self, cmd, *args, env, verbose): + def store_text(self, text): + """Called as callback when the text is ready from the web backend.""" + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', + suffix='.txt', + delete=False) as txt_file: + txt_file.write(text) + self._env['QUTE_TEXT'] = txt_file.name + + self._text_stored = True + log.procs.debug("Text stored from webview") + if self._text_stored and self._html_stored: + log.procs.debug("Both text/HTML stored, kicking off userscript!") + self._run_process(*self._args, **self._kwargs) + + def store_html(self, html): + """Called as callback when the html is ready from the web backend.""" + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', + suffix='.html', + delete=False) as html_file: + html_file.write(html) + self._env['QUTE_HTML'] = html_file.name + + self._html_stored = True + log.procs.debug("HTML stored from webview") + if self._text_stored and self._html_stored: + log.procs.debug("Both text/HTML stored, kicking off userscript!") + self._run_process(*self._args, **self._kwargs) + + def _run_process(self, cmd, *args, env=None, verbose=False): """Start the given command. Args: @@ -112,7 +148,7 @@ class _BaseUserscriptRunner(QObject): env: A dictionary of environment variables to add. verbose: Show notifications when the command started/exited. """ - self._env = {'QUTE_FIFO': self._filepath} + self._env['QUTE_FIFO'] = self._filepath if env is not None: self._env.update(env) self._proc = guiprocess.GUIProcess(self._win_id, 'userscript', @@ -144,18 +180,19 @@ class _BaseUserscriptRunner(QObject): fn, e)) self._filepath = None self._proc = None - self._env = None + self._env = {} + self._text_stored = False + self._html_stored = False - def run(self, cmd, *args, env=None, verbose=False): - """Run the userscript given. + def prepare_run(self, *args, **kwargs): + """Prepare running the userscript given. Needs to be overridden by subclasses. + The script will actually run after store_text and store_html have been + called. Args: - cmd: The command to be started. - *args: The arguments to hand to the command - env: A dictionary of environment variables to add. - verbose: Show notifications when the command started/exited. + Passed to _run_process. """ raise NotImplementedError @@ -190,7 +227,10 @@ class _POSIXUserscriptRunner(_BaseUserscriptRunner): super().__init__(win_id, parent) self._reader = None - def run(self, cmd, *args, env=None, verbose=False): + def prepare_run(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + try: # tempfile.mktemp is deprecated and discouraged, but we use it here # to create a FIFO since the only other alternative would be to @@ -209,8 +249,6 @@ class _POSIXUserscriptRunner(_BaseUserscriptRunner): self._reader = _QtFIFOReader(self._filepath) self._reader.got_line.connect(self.got_cmd) - self._run_process(cmd, *args, env=env, verbose=verbose) - @pyqtSlot() def on_proc_finished(self): self._cleanup() @@ -280,86 +318,35 @@ class _WindowsUserscriptRunner(_BaseUserscriptRunner): """Read back the commands when the process finished.""" self._cleanup() - def run(self, cmd, *args, env=None, verbose=False): + def prepare_run(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + try: self._oshandle, self._filepath = tempfile.mkstemp(text=True) except OSError as e: message.error(self._win_id, "Error while creating tempfile: " "{}".format(e)) return - self._run_process(cmd, *args, env=env, verbose=verbose) -class _DummyUserscriptRunner(QObject): +class UnsupportedError(Exception): - """Simple dummy runner which displays an error when using userscripts. + """Raised when userscripts aren't supported on this platform.""" - Used on unknown systems since we don't know what (or if any) approach will - work there. - - Signals: - finished: Always emitted. - """ - - finished = pyqtSignal() - - def __init__(self, win_id, parent=None): - # pylint: disable=unused-argument - super().__init__(parent) - - def run(self, cmd, *args, env=None, verbose=False): - """Print an error as userscripts are not supported.""" - # pylint: disable=unused-argument,unused-variable - self.finished.emit() - raise cmdexc.CommandError( - "Userscripts are not supported on this platform!") + def __str__(self): + return "Userscripts are not supported on this platform!" -# Here we basically just assign a generic UserscriptRunner class which does the -# right thing depending on the platform. -if os.name == 'posix': - UserscriptRunner = _POSIXUserscriptRunner -elif os.name == 'nt': # pragma: no cover - UserscriptRunner = _WindowsUserscriptRunner -else: # pragma: no cover - UserscriptRunner = _DummyUserscriptRunner +def run_async(tab, cmd, *args, win_id, env, verbose=False): + """Run a userscript after dumping page html/source. - -def store_source(frame): - """Store HTML/plaintext in files. - - This writes files containing the HTML/plaintext source of the page, and - returns a dict with the paths as QUTE_HTML/QUTE_TEXT. - - Args: - frame: The QWebFrame to get the info from, or None to do nothing. - - Return: - A dictionary with the needed environment variables. - - Warning: - The caller is responsible to delete the files after using them! - """ - if frame is None: - return {} - env = {} - with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', - suffix='.html', - delete=False) as html_file: - html_file.write(frame.toHtml()) - env['QUTE_HTML'] = html_file.name - with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', - suffix='.txt', - delete=False) as txt_file: - txt_file.write(frame.toPlainText()) - env['QUTE_TEXT'] = txt_file.name - return env - - -def run(cmd, *args, win_id, env, verbose=False): - """Convenience method to run a userscript. + Raises: + UnsupportedError if userscripts are not supported on the current + platform. Args: + tab: The WebKitTab/WebEngineTab to get the source from. cmd: The userscript binary to run. *args: The arguments to pass to the userscript. win_id: The window id the userscript is executed in. @@ -369,7 +356,14 @@ def run(cmd, *args, win_id, env, verbose=False): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) commandrunner = runners.CommandRunner(win_id, parent=tabbed_browser) - runner = UserscriptRunner(win_id, tabbed_browser) + + if os.name == 'posix': + runner = _POSIXUserscriptRunner(win_id, tabbed_browser) + elif os.name == 'nt': # pragma: no cover + runner = _WindowsUserscriptRunner(win_id, tabbed_browser) + else: # pragma: no cover + raise UnsupportedError + runner.got_cmd.connect( lambda cmd: log.commands.debug("Got userscript command: {}".format(cmd))) @@ -398,6 +392,9 @@ def run(cmd, *args, win_id, env, verbose=False): "userscripts", cmd) log.misc.debug("Userscript to run: {}".format(cmd_path)) - runner.run(cmd_path, *args, env=env, verbose=verbose) runner.finished.connect(commandrunner.deleteLater) runner.finished.connect(runner.deleteLater) + + runner.prepare_run(cmd_path, *args, env=env, verbose=verbose) + tab.dump_async(runner.store_html) + tab.dump_async(runner.store_text, plain=True) diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index 5c730e456..8ef19b69f 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -22,7 +22,7 @@ from collections import defaultdict from PyQt5.QtCore import Qt, QTimer, pyqtSlot -from qutebrowser.browser.webkit import webview +from qutebrowser.browser import browsertab from qutebrowser.config import config, configdata from qutebrowser.utils import objreg, log, qtutils, utils from qutebrowser.commands import cmdutils @@ -193,7 +193,7 @@ class TabCompletionModel(base.BaseCompletionModel): """Add hooks to new windows.""" window.tabbed_browser.new_tab.connect(self.on_new_tab) - @pyqtSlot(webview.WebView) + @pyqtSlot(browsertab.AbstractTab) def on_new_tab(self, tab): """Add hooks to new tabs.""" tab.url_text_changed.connect(self.rebuild) diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index df12e3084..e4539c44a 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -345,7 +345,6 @@ class MainWindow(QWidget): tabs.tab_index_changed.connect(status.tabindex.on_tab_index_changed) - tabs.current_tab_changed.connect(status.txt.on_tab_changed) tabs.cur_statusbar_message.connect(status.txt.on_statusbar_message) tabs.cur_load_started.connect(status.txt.on_load_started) diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 464c7117d..fb4c17dd3 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -329,12 +329,12 @@ class StatusBar(QWidget): log.statusbar.debug("Setting command_active to {}".format(val)) self._command_active = val elif mode == usertypes.KeyMode.caret: - webview = objreg.get('tabbed-browser', scope='window', - window=self._win_id).currentWidget() + tab = objreg.get('tabbed-browser', scope='window', + window=self._win_id).currentWidget() log.statusbar.debug("Setting caret_mode - val {}, selection " - "{}".format(val, webview.selection_enabled)) + "{}".format(val, tab.caret.selection_enabled)) if val: - if webview.selection_enabled: + if tab.caret.selection_enabled: self._set_mode_text("{} selection".format(mode.name)) self._caret_mode = CaretMode.selection else: diff --git a/qutebrowser/mainwindow/statusbar/percentage.py b/qutebrowser/mainwindow/statusbar/percentage.py index ccc1f1ecf..972e3009d 100644 --- a/qutebrowser/mainwindow/statusbar/percentage.py +++ b/qutebrowser/mainwindow/statusbar/percentage.py @@ -21,8 +21,8 @@ from PyQt5.QtCore import pyqtSlot +from qutebrowser.browser import browsertab from qutebrowser.mainwindow.statusbar import textbase -from qutebrowser.browser.webkit import webview class Percentage(textbase.TextBase): @@ -46,10 +46,12 @@ class Percentage(textbase.TextBase): self.setText('[top]') elif y == 100: self.setText('[bot]') + elif y is None: + self.setText('[???]') else: self.setText('[{:2}%]'.format(y)) - @pyqtSlot(webview.WebView) + @pyqtSlot(browsertab.AbstractTab) def on_tab_changed(self, tab): """Update scroll position when tab changed.""" - self.set_perc(*tab.scroll_pos) + self.set_perc(*tab.scroll.pos_perc()) diff --git a/qutebrowser/mainwindow/statusbar/progress.py b/qutebrowser/mainwindow/statusbar/progress.py index ded74ce1a..0f120762e 100644 --- a/qutebrowser/mainwindow/statusbar/progress.py +++ b/qutebrowser/mainwindow/statusbar/progress.py @@ -22,9 +22,9 @@ from PyQt5.QtCore import pyqtSlot, QSize from PyQt5.QtWidgets import QProgressBar, QSizePolicy -from qutebrowser.browser.webkit import webview +from qutebrowser.browser import browsertab from qutebrowser.config import style -from qutebrowser.utils import utils +from qutebrowser.utils import utils, usertypes class Progress(QProgressBar): @@ -59,15 +59,15 @@ class Progress(QProgressBar): self.setValue(0) self.show() - @pyqtSlot(webview.WebView) + @pyqtSlot(browsertab.AbstractTab) def on_tab_changed(self, tab): """Set the correct value when the current tab changed.""" if self is None: # pragma: no branch # This should never happen, but for some weird reason it does # sometimes. return # pragma: no cover - self.setValue(tab.progress) - if tab.load_status == webview.LoadStatus.loading: + self.setValue(tab.progress()) + if tab.load_status() == usertypes.LoadStatus.loading: self.show() else: self.hide() diff --git a/qutebrowser/mainwindow/statusbar/text.py b/qutebrowser/mainwindow/statusbar/text.py index 912a3e701..75614e42c 100644 --- a/qutebrowser/mainwindow/statusbar/text.py +++ b/qutebrowser/mainwindow/statusbar/text.py @@ -21,10 +21,10 @@ from PyQt5.QtCore import pyqtSlot +from qutebrowser.browser import browsertab from qutebrowser.config import config from qutebrowser.mainwindow.statusbar import textbase from qutebrowser.utils import usertypes, log, objreg -from qutebrowser.browser.webkit import webview class Text(textbase.TextBase): @@ -99,7 +99,7 @@ class Text(textbase.TextBase): """Clear jstext when page loading started.""" self._jstext = '' - @pyqtSlot(webview.WebView) + @pyqtSlot(browsertab.AbstractTab) def on_tab_changed(self, tab): """Set the correct jstext when the current tab changed.""" self._jstext = tab.statusbar_message diff --git a/qutebrowser/mainwindow/statusbar/url.py b/qutebrowser/mainwindow/statusbar/url.py index a4374fb44..2b5b52ed6 100644 --- a/qutebrowser/mainwindow/statusbar/url.py +++ b/qutebrowser/mainwindow/statusbar/url.py @@ -21,7 +21,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtProperty, Qt, QUrl -from qutebrowser.browser.webkit import webview +from qutebrowser.browser import browsertab from qutebrowser.mainwindow.statusbar import textbase from qutebrowser.config import style from qutebrowser.utils import usertypes @@ -119,11 +119,11 @@ class UrlText(textbase.TextBase): Args: status_str: The LoadStatus as string. """ - status = webview.LoadStatus[status_str] - if status in (webview.LoadStatus.success, - webview.LoadStatus.success_https, - webview.LoadStatus.error, - webview.LoadStatus.warn): + status = usertypes.LoadStatus[status_str] + if status in (usertypes.LoadStatus.success, + usertypes.LoadStatus.success_https, + usertypes.LoadStatus.error, + usertypes.LoadStatus.warn): self._normal_url_type = UrlType[status_str] else: self._normal_url_type = UrlType.normal @@ -140,8 +140,8 @@ class UrlText(textbase.TextBase): self._normal_url_type = UrlType.normal self._update_url() - @pyqtSlot(str, str, str) - def set_hover_url(self, link, _title, _text): + @pyqtSlot(str) + def set_hover_url(self, link): """Setter to be used as a Qt slot. Saves old shown URL in self._old_url and restores it later if a link is @@ -149,8 +149,6 @@ class UrlText(textbase.TextBase): Args: link: The link which was hovered (string) - _title: The title of the hovered link (string) - _text: The text of the hovered link (string) """ if link: qurl = QUrl(link) @@ -162,10 +160,10 @@ class UrlText(textbase.TextBase): self._hover_url = None self._update_url() - @pyqtSlot(webview.WebView) + @pyqtSlot(browsertab.AbstractTab) def on_tab_changed(self, tab): """Update URL if the tab changed.""" self._hover_url = None - self._normal_url = tab.cur_url.toDisplayString() - self.on_load_status_changed(tab.load_status.name) + self._normal_url = tab.url().toDisplayString() + self.on_load_status_changed(tab.load_status().name) self._update_url() diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 682e80c78..0cdaf8164 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -29,8 +29,7 @@ from PyQt5.QtGui import QIcon from qutebrowser.config import config from qutebrowser.keyinput import modeman from qutebrowser.mainwindow import tabwidget -from qutebrowser.browser import signalfilter -from qutebrowser.browser.webkit import webview +from qutebrowser.browser import signalfilter, browsertab from qutebrowser.utils import (log, usertypes, utils, qtutils, objreg, urlutils, message) @@ -56,8 +55,8 @@ class TabbedBrowser(tabwidget.TabWidget): emitted if the signal occurred in the current tab. Attributes: - search_text/search_flags: Search parameters which are shared between - all tabs. + search_text/search_options: Search parameters which are shared between + all tabs. _win_id: The window ID this tabbedbrowser is associated with. _filter: A SignalFilter instance. _now_focused: The tab which is focused now. @@ -71,13 +70,13 @@ class TabbedBrowser(tabwidget.TabWidget): default_window_icon: The qutebrowser window icon Signals: - cur_progress: Progress of the current tab changed (loadProgress). - cur_load_started: Current tab started loading (loadStarted) - cur_load_finished: Current tab finished loading (loadFinished) + cur_progress: Progress of the current tab changed (load_progress). + cur_load_started: Current tab started loading (load_started) + cur_load_finished: Current tab finished loading (load_finished) cur_statusbar_message: Current tab got a statusbar message (statusBarMessage) cur_url_text_changed: Current URL text changed. - cur_link_hovered: Link hovered in current tab (linkHovered) + cur_link_hovered: Link hovered in current tab (link_hovered) cur_scroll_perc_changed: Scroll percentage of current tab changed. arg 1: x-position in %. arg 2: y-position in %. @@ -86,7 +85,7 @@ class TabbedBrowser(tabwidget.TabWidget): resized: Emitted when the browser window has resized, so the completion widget can adjust its size to it. arg: The new size. - current_tab_changed: The current tab changed to the emitted WebView. + current_tab_changed: The current tab changed to the emitted tab. new_tab: Emits the new WebView and its index when a new tab is opened. """ @@ -95,13 +94,13 @@ class TabbedBrowser(tabwidget.TabWidget): cur_load_finished = pyqtSignal(bool) cur_statusbar_message = pyqtSignal(str) cur_url_text_changed = pyqtSignal(str) - cur_link_hovered = pyqtSignal(str, str, str) + cur_link_hovered = pyqtSignal(str) cur_scroll_perc_changed = pyqtSignal(int, int) cur_load_status_changed = pyqtSignal(str) close_window = pyqtSignal() resized = pyqtSignal('QRect') - current_tab_changed = pyqtSignal(webview.WebView) - new_tab = pyqtSignal(webview.WebView, int) + current_tab_changed = pyqtSignal(browsertab.AbstractTab) + new_tab = pyqtSignal(browsertab.AbstractTab, int) def __init__(self, win_id, parent=None): super().__init__(win_id, parent) @@ -117,7 +116,7 @@ class TabbedBrowser(tabwidget.TabWidget): self._filter = signalfilter.SignalFilter(win_id, self) self._now_focused = None self.search_text = None - self.search_flags = 0 + self.search_options = {} self._local_marks = {} self._global_marks = {} self.default_window_icon = self.window().windowIcon() @@ -170,22 +169,21 @@ class TabbedBrowser(tabwidget.TabWidget): def _connect_tab_signals(self, tab): """Set up the needed signals for tab.""" - page = tab.page() - frame = page.mainFrame() # filtered signals - tab.linkHovered.connect( + tab.link_hovered.connect( self._filter.create(self.cur_link_hovered, tab)) - tab.loadProgress.connect( + tab.load_progress.connect( self._filter.create(self.cur_progress, tab)) - frame.loadFinished.connect( + tab.load_finished.connect( self._filter.create(self.cur_load_finished, tab)) - frame.loadStarted.connect( + tab.load_started.connect( self._filter.create(self.cur_load_started, tab)) - tab.statusBarMessage.connect( - self._filter.create(self.cur_statusbar_message, tab)) - tab.scroll_pos_changed.connect( + # https://github.com/The-Compiler/qutebrowser/issues/1579 + # tab.statusBarMessage.connect( + # self._filter.create(self.cur_statusbar_message, tab)) + tab.scroll.perc_changed.connect( self._filter.create(self.cur_scroll_perc_changed, tab)) - tab.scroll_pos_changed.connect(self.on_scroll_pos_changed) + tab.scroll.perc_changed.connect(self.on_scroll_pos_changed) tab.url_text_changed.connect( self._filter.create(self.cur_url_text_changed, tab)) tab.load_status_changed.connect( @@ -193,18 +191,19 @@ class TabbedBrowser(tabwidget.TabWidget): tab.url_text_changed.connect( functools.partial(self.on_url_text_changed, tab)) # misc - tab.titleChanged.connect( + tab.title_changed.connect( functools.partial(self.on_title_changed, tab)) - tab.iconChanged.connect( + tab.icon_changed.connect( functools.partial(self.on_icon_changed, tab)) - tab.loadProgress.connect( + tab.load_progress.connect( functools.partial(self.on_load_progress, tab)) - frame.loadFinished.connect( + tab.load_finished.connect( functools.partial(self.on_load_finished, tab)) - frame.loadStarted.connect( + tab.load_started.connect( functools.partial(self.on_load_started, tab)) - page.windowCloseRequested.connect( + tab.window_close_requested.connect( functools.partial(self.on_window_close_requested, tab)) + tab.new_tab_requested.connect(self.tabopen) def current_url(self): """Get the URL of the current tab. @@ -265,11 +264,11 @@ class TabbedBrowser(tabwidget.TabWidget): window=self._win_id): objreg.delete('last-focused-tab', scope='window', window=self._win_id) - if tab.cur_url.isValid(): - history_data = qtutils.serialize(tab.history()) - entry = UndoEntry(tab.cur_url, history_data) + if tab.url().isValid(): + history_data = tab.history.serialize() + entry = UndoEntry(tab.url(), history_data) self._undo_stack.append(entry) - elif tab.cur_url.isEmpty(): + elif tab.url().isEmpty(): # There are some good reasons why a URL could be empty # (target="_blank" with a download, see [1]), so we silently ignore # this. @@ -279,7 +278,7 @@ class TabbedBrowser(tabwidget.TabWidget): # We display a warnings for URLs which are not empty but invalid - # but we don't return here because we want the tab to close either # way. - urlutils.invalid_url_error(self._win_id, tab.cur_url, "saving tab") + urlutils.invalid_url_error(self._win_id, tab.url(), "saving tab") tab.shutdown() self.removeTab(idx) tab.deleteLater() @@ -291,13 +290,13 @@ class TabbedBrowser(tabwidget.TabWidget): use_current_tab = False if last_close in ['blank', 'startpage', 'default-page']: only_one_tab_open = self.count() == 1 - no_history = self.widget(0).history().count() == 1 + no_history = len(self.widget(0).history) == 1 urls = { 'blank': QUrl('about:blank'), 'startpage': QUrl(config.get('general', 'startpage')[0]), 'default-page': config.get('general', 'default-page'), } - first_tab_url = self.widget(0).page().mainFrame().requestedUrl() + first_tab_url = self.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 @@ -312,7 +311,7 @@ class TabbedBrowser(tabwidget.TabWidget): else: newtab = self.tabopen(url, background=False) - qtutils.deserialize(history_data, newtab.history()) + newtab.history.deserialize(history_data) @pyqtSlot('QUrl', bool) def openurl(self, url, newtab): @@ -338,7 +337,7 @@ class TabbedBrowser(tabwidget.TabWidget): return self.close_tab(tab) - @pyqtSlot(webview.WebView) + @pyqtSlot(browsertab.AbstractTab) def on_window_close_requested(self, widget): """Close a tab with a widget given.""" try: @@ -347,6 +346,7 @@ class TabbedBrowser(tabwidget.TabWidget): log.webview.debug("Requested to close {!r} which does not " "exist!".format(widget)) + @pyqtSlot('QUrl') @pyqtSlot('QUrl', bool) def tabopen(self, url=None, background=None, explicit=False): """Open a new tab with a given URL. @@ -378,10 +378,13 @@ class TabbedBrowser(tabwidget.TabWidget): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=window.win_id) return tabbed_browser.tabopen(url, background, explicit) - tab = webview.WebView(self._win_id, self) + + tab = browsertab.create(win_id=self._win_id, parent=self) self._connect_tab_signals(tab) + idx = self._get_new_tab_idx(explicit) self.insertTab(idx, tab, "") + if url is not None: tab.openurl(url) if background is None: @@ -457,8 +460,8 @@ class TabbedBrowser(tabwidget.TabWidget): # We can get signals for tabs we already deleted... return self.update_tab_title(idx) - if tab.keep_icon: - tab.keep_icon = False + if tab.data.keep_icon: + tab.data.keep_icon = False else: self.setTabIcon(idx, QIcon()) if (config.get('tabs', 'tabs-are-windows') and @@ -475,11 +478,11 @@ class TabbedBrowser(tabwidget.TabWidget): modeman.maybe_leave(self._win_id, usertypes.KeyMode.hint, 'load started') - @pyqtSlot(webview.WebView, str) + @pyqtSlot(browsertab.AbstractTab, str) def on_title_changed(self, tab, text): """Set the title of a tab. - Slot for the titleChanged signal of any tab. + Slot for the title_changed signal of any tab. Args: tab: The WebView where the title was changed. @@ -499,7 +502,7 @@ class TabbedBrowser(tabwidget.TabWidget): if idx == self.currentIndex(): self.update_window_title() - @pyqtSlot(webview.WebView, str) + @pyqtSlot(browsertab.AbstractTab, str) def on_url_text_changed(self, tab, url): """Set the new URL as title if there's no title yet. @@ -515,14 +518,15 @@ class TabbedBrowser(tabwidget.TabWidget): if not self.page_title(idx): self.set_page_title(idx, url) - @pyqtSlot(webview.WebView) - def on_icon_changed(self, tab): + @pyqtSlot(browsertab.AbstractTab, QIcon) + def on_icon_changed(self, tab, icon): """Set the icon of a tab. Slot for the iconChanged signal of any tab. Args: tab: The WebView where the title was changed. + icon: The new icon """ if not config.get('tabs', 'show-favicons'): return @@ -531,9 +535,9 @@ class TabbedBrowser(tabwidget.TabWidget): except TabDeletedError: # We can get signals for tabs we already deleted... return - self.setTabIcon(idx, tab.icon()) + self.setTabIcon(idx, icon) if config.get('tabs', 'tabs-are-windows'): - self.window().setWindowIcon(tab.icon()) + self.window().setWindowIcon(icon) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): @@ -589,25 +593,20 @@ class TabbedBrowser(tabwidget.TabWidget): if idx == self.currentIndex(): self.update_window_title() - def on_load_finished(self, tab): - """Adjust tab indicator when loading finished. - - We don't take loadFinished's ok argument here as it always seems to be - true when the QWebPage has an ErrorPageExtension implemented. - See https://github.com/The-Compiler/qutebrowser/issues/84 - """ + def on_load_finished(self, tab, ok): + """Adjust tab indicator when loading finished.""" try: idx = self._tab_index(tab) except TabDeletedError: # We can get signals for tabs we already deleted... return - if tab.page().error_occurred: - color = config.get('colors', 'tabs.indicator.error') - else: + if ok: start = config.get('colors', 'tabs.indicator.start') stop = config.get('colors', 'tabs.indicator.stop') system = config.get('colors', 'tabs.indicator.system') color = utils.interpolate_color(start, stop, 100, system) + else: + color = config.get('colors', 'tabs.indicator.error') self.set_tab_indicator_color(idx, color) self.update_tab_title(idx) if idx == self.currentIndex(): @@ -653,7 +652,7 @@ class TabbedBrowser(tabwidget.TabWidget): if key != "'": message.error(self._win_id, "Failed to set mark: url invalid") return - point = self.currentWidget().page().currentFrame().scrollPosition() + point = self.currentWidget().scroll.pos_px() if key.isupper(): self._global_marks[key] = point, url @@ -674,7 +673,7 @@ class TabbedBrowser(tabwidget.TabWidget): except qtutils.QtValueError: urlkey = None - frame = self.currentWidget().page().currentFrame() + tab = self.currentWidget() if key.isupper(): if key in self._global_marks: @@ -684,7 +683,7 @@ class TabbedBrowser(tabwidget.TabWidget): def callback(ok): if ok: self.cur_load_finished.disconnect(callback) - frame.setScrollPosition(point) + tab.scroll.to_point(point) self.openurl(url, newtab=False) self.cur_load_finished.connect(callback) @@ -700,6 +699,6 @@ class TabbedBrowser(tabwidget.TabWidget): # "'" would just jump to the current position every time self.set_mark("'") - frame.setScrollPosition(point) + tab.scroll.to_point(point) else: message.error(self._win_id, "Mark {} is not set".format(key)) diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 2c1ab850c..6c7f63866 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -29,7 +29,6 @@ from PyQt5.QtGui import QIcon, QPalette, QColor from qutebrowser.utils import qtutils, objreg, utils, usertypes from qutebrowser.config import config -from qutebrowser.browser.webkit import webview PixelMetrics = usertypes.enum('PixelMetrics', ['icon_padding'], @@ -108,17 +107,17 @@ class TabWidget(QTabWidget): def get_tab_fields(self, idx): """Get the tab field data.""" - widget = self.widget(idx) + tab = self.widget(idx) page_title = self.page_title(idx) fields = {} - fields['id'] = widget.tab_id + fields['id'] = tab.tab_id fields['title'] = page_title fields['title_sep'] = ' - ' if page_title else '' - fields['perc_raw'] = widget.progress + fields['perc_raw'] = tab.progress() - if widget.load_status == webview.LoadStatus.loading: - fields['perc'] = '[{}%] '.format(widget.progress) + if tab.load_status() == usertypes.LoadStatus.loading: + fields['perc'] = '[{}%] '.format(tab.progress()) else: fields['perc'] = '' @@ -127,8 +126,10 @@ class TabWidget(QTabWidget): except qtutils.QtValueError: fields['host'] = '' - y = widget.scroll_pos[1] - if y <= 0: + y = tab.scroll.pos_perc()[1] + if y is None: + scroll_pos = '???' + elif y <= 0: scroll_pos = 'top' elif y >= 100: scroll_pos = 'bot' @@ -224,11 +225,11 @@ class TabWidget(QTabWidget): Return: The tab URL as QUrl. """ - widget = self.widget(idx) - if widget is None: + tab = self.widget(idx) + if tab is None: url = QUrl() else: - url = widget.cur_url + url = tab.url() # It's possible for url to be invalid, but the caller will handle that. qtutils.ensure_valid(url) return url diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 0b745d727..eb1a7e7d6 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -477,6 +477,29 @@ class ExceptionCrashDialog(_CrashDialog): else: self.reject() + @pyqtSlot() + def on_report_clicked(self): + """Ignore reports with the QtWebEngine backend. + + FIXME:qtwebengine Remove this when QtWebEngine is working better! + """ + try: + backend = objreg.get('args').backend + except Exception: + backend = 'webkit' + + if backend == 'webkit': + super().on_report_clicked() + return + + title = "Crash reports disabled with QtWebEngine!" + text = ("You're using the QtWebEngine backend which is not intended " + "for general usage yet. Crash reports with that backend have " + "been disabled.") + box = msgbox.msgbox(parent=self, title=title, text=text, + icon=QMessageBox.Critical) + box.finished.connect(self.finish) + class FatalCrashDialog(_CrashDialog): diff --git a/qutebrowser/misc/crashsignal.py b/qutebrowser/misc/crashsignal.py index 9ceb49a22..168e23062 100644 --- a/qutebrowser/misc/crashsignal.py +++ b/qutebrowser/misc/crashsignal.py @@ -116,7 +116,7 @@ class CrashHandler(QObject): window=win_id) for tab in tabbed_browser.widgets(): try: - urlstr = tab.cur_url.toString( + urlstr = tab.url().toString( QUrl.RemovePassword | QUrl.FullyEncoded) if urlstr: win_pages.append(urlstr) diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index b863d63fd..89c665404 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -264,6 +264,17 @@ def check_libraries(): _die(text, e) +def maybe_import_webengine(): + """Import QtWebEngineWidgets before QApplication is created. + + See https://github.com/The-Compiler/qutebrowser/pull/1629#issuecomment-231613099 + """ + try: + from PyQt5 import QtWebEngineWidgets + except ImportError: + pass + + def remove_inputhook(): """Remove the PyQt input hook. @@ -309,4 +320,5 @@ def earlyinit(args): check_ssl_support() remove_inputhook() check_libraries() + maybe_import_webengine() init_log(args) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index 13d6166f9..d236f2475 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -130,6 +130,55 @@ class SessionManager(QObject): else: return True + def _save_tab_item(self, tab, idx, item): + """Save a single history item in a tab. + + Args: + tab: The tab to save. + idx: The index of the current history item. + item: The history item. + + Return: + A dict with the saved data for this item. + """ + data = { + 'url': bytes(item.url().toEncoded()).decode('ascii'), + } + + if item.title(): + data['title'] = item.title() + else: + # https://github.com/The-Compiler/qutebrowser/issues/879 + if tab.history.current_idx() == idx: + data['title'] = tab.title() + else: + data['title'] = data['url'] + + if item.originalUrl() != item.url(): + encoded = item.originalUrl().toEncoded() + data['original-url'] = bytes(encoded).decode('ascii') + + if tab.history.current_idx() == idx: + data['active'] = True + + try: + user_data = item.userData() + except AttributeError: + # QtWebEngine + user_data = None + + if tab.history.current_idx() == idx: + pos = tab.scroll.pos_px() + data['zoom'] = tab.zoom.factor() + data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} + elif user_data is not None: + if 'zoom' in user_data: + data['zoom'] = user_data['zoom'] + if 'scroll-pos' in user_data: + pos = user_data['scroll-pos'] + data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} + return data + def _save_tab(self, tab, active): """Get a dict with data for a single tab. @@ -140,42 +189,9 @@ class SessionManager(QObject): data = {'history': []} if active: data['active'] = True - history = tab.page().history() - for idx, item in enumerate(history.items()): + for idx, item in enumerate(tab.history): qtutils.ensure_valid(item) - - item_data = { - 'url': bytes(item.url().toEncoded()).decode('ascii'), - } - - if item.title(): - item_data['title'] = item.title() - else: - # https://github.com/The-Compiler/qutebrowser/issues/879 - if history.currentItemIndex() == idx: - item_data['title'] = tab.page().mainFrame().title() - else: - item_data['title'] = item_data['url'] - - if item.originalUrl() != item.url(): - encoded = item.originalUrl().toEncoded() - item_data['original-url'] = bytes(encoded).decode('ascii') - - if history.currentItemIndex() == idx: - item_data['active'] = True - - user_data = item.userData() - if history.currentItemIndex() == idx: - pos = tab.page().mainFrame().scrollPosition() - item_data['zoom'] = tab.zoomFactor() - item_data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} - elif user_data is not None: - if 'zoom' in user_data: - item_data['zoom'] = user_data['zoom'] - if 'scroll-pos' in user_data: - pos = user_data['scroll-pos'] - item_data['scroll-pos'] = {'x': pos.x(), 'y': pos.y()} - + item_data = self._save_tab_item(tab, idx, item) data['history'].append(item_data) return data @@ -300,9 +316,9 @@ class SessionManager(QObject): active=active, user_data=user_data) entries.append(entry) if active: - new_tab.titleChanged.emit(histentry['title']) + new_tab.title_changed.emit(histentry['title']) try: - new_tab.page().load_history(entries) + new_tab.history.load_items(entries) except ValueError as e: raise SessionError(e) diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py index 39c6e7a31..a2ffd8c02 100644 --- a/qutebrowser/qutebrowser.py +++ b/qutebrowser/qutebrowser.py @@ -69,6 +69,10 @@ def get_argparser(): 'tab-silent', 'tab-bg-silent', 'window'], help="How URLs should be opened if there is already a " "qutebrowser instance running.") + parser.add_argument('--backend', choices=['webkit', 'webengine'], + # help="Which backend to use.", + help=argparse.SUPPRESS, default='webkit') + parser.add_argument('--json-args', help=argparse.SUPPRESS) parser.add_argument('--temp-basedir-restarted', help=argparse.SUPPRESS) diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 37c1c6bcd..7f67ae9f2 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -29,6 +29,7 @@ import faulthandler import traceback import warnings import json +import inspect from PyQt5 import QtCore # Optional imports @@ -130,6 +131,15 @@ sessions = logging.getLogger('sessions') ram_handler = None +def stub(suffix=''): + """Show a STUB: message for the calling function.""" + function = inspect.stack()[1][3] + text = "STUB: {}".format(function) + if suffix: + text = '{} ({})'.format(text, suffix) + misc.warning(text) + + class CriticalQtWarning(Exception): """Exception raised when there's a critical Qt warning.""" diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index 3cc703cf8..6d2999f32 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -153,6 +153,8 @@ def _get_tab_registry(win_id, tab_id): win_id = window.win_id elif win_id is not None: window = window_registry[win_id] + else: + raise TypeError("window is None with scope tab!") if tab_id == 'current': tabbed_browser = get('tabbed-browser', scope='window', window=win_id) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index a2c4d0429..872531d4f 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -246,6 +246,15 @@ Exit = enum('Exit', ['ok', 'reserved', 'exception', 'err_ipc', 'err_init', 'err_config', 'err_key_config'], is_int=True, start=0) +# Load status of a tab +LoadStatus = enum('LoadStatus', ['none', 'success', 'success_https', 'error', + 'warn', 'loading']) + + +# Backend of a tab +Backend = enum('Backend', ['QtWebKit', 'QtWebEngine']) + + class Question(QObject): """A question asked to the user, e.g. via the status bar. diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 39e552e3c..49c4ba443 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -70,6 +70,8 @@ PERFECT_FILES = [ ('tests/unit/browser/test_signalfilter.py', 'qutebrowser/browser/signalfilter.py'), + # ('tests/unit/browser/test_tab.py', + # 'qutebrowser/browser/tab.py'), ('tests/unit/keyinput/test_basekeyparser.py', 'qutebrowser/keyinput/basekeyparser.py'), diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 0c9417224..c4e137fe3 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -760,7 +760,7 @@ Feature: Tab management And I open data/search.html in a new tab And I open data/scroll.html in a new tab And I run :buffer "Searching text" - And I wait for "Current tab changed, focusing " in the log + And I wait for "Current tab changed, focusing " in the log Then the following tabs should be open: - data/title.html - data/search.html (active) @@ -777,7 +777,7 @@ Feature: Tab management And I open data/caret.html in a new window And I open data/paste_primary.html in a new tab And I run :buffer "Scrolling" - And I wait for "Focus object changed: " in the log + And I wait for "Focus object changed: " in the log Then the session should look like: windows: - active: true @@ -816,7 +816,7 @@ Feature: Tab management And I open data/paste_primary.html in a new tab And I wait until data/caret.html is loaded And I run :buffer "0/2" - And I wait for "Focus object changed: " in the log + And I wait for "Focus object changed: " in the log Then the session should look like: windows: - active: true diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index b209dfb94..33531fe90 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -202,21 +202,22 @@ class QuteProc(testprocess.Process): self._log(log_line) start_okay_message_load = ( - "load status for : LoadStatus.success") + "load status for : LoadStatus.success") start_okay_message_focus = ( "Focus object changed: " - "") + "") if (log_line.category == 'ipc' and log_line.message.startswith("Listening as ")): self._ipc_socket = log_line.message.split(' ', maxsplit=2)[2] elif (log_line.category == 'webview' and - log_line.message == start_okay_message_load): + testutils.pattern_match(pattern=start_okay_message_load, + value=log_line.message)): self._is_ready('load') elif (log_line.category == 'misc' and - log_line.message == start_okay_message_focus): + testutils.pattern_match(pattern=start_okay_message_focus, + value=log_line.message)): self._is_ready('focus') elif (log_line.category == 'init' and log_line.module == 'standarddir' and @@ -291,8 +292,7 @@ class QuteProc(testprocess.Process): # Try to complain about the most common mistake when accidentally # loading external resources. is_ddg_load = testutils.pattern_match( - pattern="load status for : *", + pattern="load status for <* tab_id=* url='*duckduckgo*'>: *", value=msg.message) return msg.loglevel > logging.INFO or is_js_error or is_ddg_load @@ -442,8 +442,7 @@ class QuteProc(testprocess.Process): assert url pattern = re.compile( - r"(load status for " - r": LoadStatus\.{load_status}|fetch: " r"PyQt5\.QtCore\.QUrl\('{url}'\) -> .*)".format( load_status=re.escape(load_status), url=re.escape(url))) diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index eb8dd9a97..83a200ebd 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -29,6 +29,7 @@ import collections import itertools import textwrap import unittest.mock +import types import pytest @@ -37,8 +38,9 @@ from qutebrowser.config import config from qutebrowser.utils import objreg from qutebrowser.browser.webkit import cookies from qutebrowser.misc import savemanager +from qutebrowser.keyinput import modeman -from PyQt5.QtCore import QEvent, QSize, Qt +from PyQt5.QtCore import PYQT_VERSION, QEvent, QSize, Qt from PyQt5.QtGui import QKeyEvent from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout from PyQt5.QtNetwork import QNetworkCookieJar @@ -122,6 +124,14 @@ def tab_registry(win_registry): objreg.delete('tab-registry', scope='window', window=0) +@pytest.fixture +def fake_web_tab(stubs, tab_registry, qapp): + """Fixture providing the FakeWebTab *class*.""" + if PYQT_VERSION < 0x050600: + pytest.skip('Causes segfaults, see #1638') + return stubs.FakeWebTab + + def _generate_cmdline_tests(): """Generate testcases for test_split_binding.""" # pylint: disable=invalid-name @@ -377,3 +387,20 @@ def fake_save_manager(): objreg.register('save-manager', fake_save_manager) yield fake_save_manager objreg.delete('save-manager') + + +@pytest.yield_fixture +def fake_args(): + ns = types.SimpleNamespace() + objreg.register('args', ns) + yield ns + objreg.delete('args') + + +@pytest.yield_fixture +def mode_manager(win_registry, config_stub, qapp): + config_stub.data = {'input': {'forward-unbound-keys': 'auto'}} + mm = modeman.ModeManager(0) + objreg.register('mode-manager', mm, scope='window', window=0) + yield mm + objreg.delete('mode-manager', scope='window', window=0) diff --git a/tests/helpers/messagemock.py b/tests/helpers/messagemock.py index 485f2c745..cf27cb8f1 100644 --- a/tests/helpers/messagemock.py +++ b/tests/helpers/messagemock.py @@ -53,7 +53,8 @@ class MessageMock: self._caplog = caplog self.messages = [] - def _handle(self, level, win_id, text, immediately=False): + def _handle(self, level, win_id, text, immediately=False, *, + stack=None): # pylint: disable=unused-variable log_levels = { Level.error: logging.ERROR, Level.info: logging.INFO, diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index d6c8b69d0..0119c612c 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -# pylint: disable=invalid-name +# pylint: disable=invalid-name,abstract-method """Fake objects/stubs.""" @@ -27,10 +27,12 @@ from unittest import mock from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) -from PyQt5.QtWidgets import QCommonStyle, QWidget, QLineEdit +from PyQt5.QtWidgets import QCommonStyle, QLineEdit -from qutebrowser.browser.webkit import webview, history +from qutebrowser.browser import browsertab +from qutebrowser.browser.webkit import history from qutebrowser.config import configexc +from qutebrowser.utils import usertypes from qutebrowser.mainwindow import mainwindow @@ -223,24 +225,44 @@ def fake_qprocess(): return m -class FakeWebView(QWidget): +class FakeWebTabScroller(browsertab.AbstractScroller): - """Fake WebView which can be added to a tab.""" + """Fake AbstractScroller to use in tests.""" - url_text_changed = pyqtSignal(str) - shutting_down = pyqtSignal() - - def __init__(self, url=FakeUrl(), title='', tab_id=0): + def __init__(self, pos_perc): super().__init__() - self.progress = 0 - self.scroll_pos = (-1, -1) - self.load_status = webview.LoadStatus.none - self.tab_id = tab_id - self.cur_url = url - self.title = title + self._pos_perc = pos_perc + + def pos_perc(self): + return self._pos_perc + + +class FakeWebTab(browsertab.AbstractTab): + + """Fake AbstractTab to use in tests.""" + + def __init__(self, url=FakeUrl(), title='', tab_id=0, *, + scroll_pos_perc=(0, 0), + load_status=usertypes.LoadStatus.success, + progress=0): + super().__init__(win_id=0) + self._load_status = load_status + self._title = title + self._url = url + self._progress = progress + self.scroll = FakeWebTabScroller(scroll_pos_perc) def url(self): - return self.cur_url + return self._url + + def title(self): + return self._title + + def progress(self): + return self._progress + + def load_status(self): + return self._load_status class FakeSignal: @@ -522,7 +544,7 @@ class TabbedBrowserStub(QObject): """Stub for the tabbed-browser object.""" - new_tab = pyqtSignal(webview.WebView, int) + new_tab = pyqtSignal(browsertab.AbstractTab, int) def __init__(self, parent=None): super().__init__(parent) @@ -536,7 +558,7 @@ class TabbedBrowserStub(QObject): return self.tabs[i] def page_title(self, i): - return self.tabs[i].title + return self.tabs[i].title() def on_tab_close_requested(self, idx): del self.tabs[idx] diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/browser/test_adblock.py index f5b54b6cf..be801de73 100644 --- a/tests/unit/browser/test_adblock.py +++ b/tests/unit/browser/test_adblock.py @@ -69,13 +69,10 @@ class BaseDirStub: self.basedir = None -@pytest.yield_fixture -def basedir(): +@pytest.fixture +def basedir(fake_args): """Register a Fake basedir.""" - args = BaseDirStub() - objreg.register('args', args) - yield - objreg.delete('args') + fake_args.basedir = None class FakeDownloadItem(QObject): diff --git a/tests/unit/browser/test_tab.py b/tests/unit/browser/test_tab.py new file mode 100644 index 000000000..722fa04d0 --- /dev/null +++ b/tests/unit/browser/test_tab.py @@ -0,0 +1,101 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# 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 . + +import pytest + +from PyQt5.QtCore import PYQT_VERSION, pyqtSignal, QPoint + +from qutebrowser.browser import browsertab +from qutebrowser.keyinput import modeman + +try: + from PyQt5.QtWebKitWidgets import QWebView + + class WebView(QWebView): + mouse_wheel_zoom = pyqtSignal(QPoint) +except ImportError: + WebView = None + +try: + from PyQt5.QtWebEngineWidgets import QWebEngineView + + class WebEngineView(QWebEngineView): + mouse_wheel_zoom = pyqtSignal(QPoint) +except ImportError: + WebEngineView = None + + +@pytest.mark.skipif(PYQT_VERSION < 0x050600, + reason='Causes segfaults, see #1638') +@pytest.mark.parametrize('view', [WebView, WebEngineView]) +def test_tab(qtbot, view, config_stub, tab_registry): + config_stub.data = { + 'input': { + 'forward-unbound-keys': 'auto' + }, + 'ui': { + 'zoom-levels': [100], + 'default-zoom': 100, + } + } + + if view is None: + pytest.skip("View not available") + + w = view() + qtbot.add_widget(w) + + tab_w = browsertab.AbstractTab(win_id=0) + qtbot.add_widget(tab_w) + tab_w.show() + + assert tab_w.win_id == 0 + assert tab_w._widget is None + + mode_manager = modeman.ModeManager(0) + + tab_w.history = browsertab.AbstractHistory(tab_w) + tab_w.scroll = browsertab.AbstractScroller(parent=tab_w) + tab_w.caret = browsertab.AbstractCaret(win_id=tab_w.win_id, + mode_manager=mode_manager, + tab=tab_w, parent=tab_w) + tab_w.zoom = browsertab.AbstractZoom(win_id=tab_w.win_id) + tab_w.search = browsertab.AbstractSearch(parent=tab_w) + + tab_w._set_widget(w) + assert tab_w._widget is w + assert tab_w.history._tab is tab_w + assert tab_w.history._history is w.history() + assert w.parent() is tab_w + + +class TestTabData: + + def test_known_attr(self): + data = browsertab.TabData() + assert not data.keep_icon + data.keep_icon = True + assert data.keep_icon + + def test_unknown_attr(self): + data = browsertab.TabData() + with pytest.raises(AttributeError): + data.bar = 42 # pylint: disable=assigning-non-slot + with pytest.raises(AttributeError): + data.bar # pylint: disable=pointless-statement diff --git a/tests/unit/commands/test_cmdutils.py b/tests/unit/commands/test_cmdutils.py index 757bc2061..e03bc2b1e 100644 --- a/tests/unit/commands/test_cmdutils.py +++ b/tests/unit/commands/test_cmdutils.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +# pylint: disable=unused-variable + """Tests for qutebrowser.commands.cmdutils.""" import pytest @@ -32,6 +34,19 @@ def clear_globals(monkeypatch): monkeypatch.setattr(cmdutils, 'aliases', []) +def _get_cmd(*args, **kwargs): + """Get a command object created via @cmdutils.register. + + Args: + Passed to @cmdutils.register decorator + """ + @cmdutils.register(*args, **kwargs) + def fun(): + """Blah.""" + pass + return cmdutils.cmd_dict['fun'] + + class TestCheckOverflow: def test_good(self): @@ -87,8 +102,6 @@ class TestCheckExclusive: class TestRegister: - # pylint: disable=unused-variable - def test_simple(self): @cmdutils.register() def fun(): @@ -306,8 +319,6 @@ class TestArgument: """Test the @cmdutils.argument decorator.""" - # pylint: disable=unused-variable - def test_invalid_argument(self): with pytest.raises(ValueError) as excinfo: @cmdutils.argument('foo') @@ -350,3 +361,51 @@ class TestArgument: pass assert str(excinfo.value) == "Argument marked as both count/win_id!" + + +class TestRun: + + @pytest.fixture(autouse=True) + def patching(self, mode_manager, fake_args): + fake_args.backend = 'webkit' + + @pytest.mark.parametrize('backend, used, ok', [ + (usertypes.Backend.QtWebEngine, 'webengine', True), + (usertypes.Backend.QtWebEngine, 'webkit', False), + (usertypes.Backend.QtWebKit, 'webengine', False), + (usertypes.Backend.QtWebKit, 'webkit', True), + (None, 'webengine', True), + (None, 'webkit', True), + ]) + def test_backend(self, fake_args, backend, used, ok): + fake_args.backend = used + cmd = _get_cmd(backend=backend) + if ok: + cmd.run(win_id=0) + else: + with pytest.raises(cmdexc.PrerequisitesError) as excinfo: + cmd.run(win_id=0) + assert str(excinfo.value).endswith(' backend.') + + def test_no_args(self): + cmd = _get_cmd() + cmd.run(win_id=0) + + def test_instance_unavailable_with_backend(self, fake_args): + """Test what happens when a backend doesn't have an objreg object. + + For example, QtWebEngine doesn't have 'hintmanager' registered. We make + sure the backend checking happens before resolving the instance, so we + display an error instead of crashing. + """ + @cmdutils.register(instance='doesnotexist', + backend=usertypes.Backend.QtWebEngine) + def fun(self): + """Blah.""" + pass + + fake_args.backend = 'webkit' + cmd = cmdutils.cmd_dict['fun'] + with pytest.raises(cmdexc.PrerequisitesError) as excinfo: + cmd.run(win_id=0) + assert str(excinfo.value).endswith(' backend.') diff --git a/tests/unit/commands/test_userscripts.py b/tests/unit/commands/test_userscripts.py index 09e74d170..af3a10c7c 100644 --- a/tests/unit/commands/test_userscripts.py +++ b/tests/unit/commands/test_userscripts.py @@ -26,7 +26,7 @@ import signal import pytest from PyQt5.QtCore import QFileSystemWatcher -from qutebrowser.commands import userscripts, cmdexc +from qutebrowser.commands import userscripts @pytest.fixture(autouse=True) @@ -80,7 +80,9 @@ def test_command(qtbot, py_proc, runner): f.write('foo\n') """) with qtbot.waitSignal(runner.got_cmd, timeout=10000) as blocker: - runner.run(cmd, *args) + runner.prepare_run(cmd, *args) + runner.store_html('') + runner.store_text('') assert blocker.args == ['foo'] @@ -100,7 +102,9 @@ def test_custom_env(qtbot, monkeypatch, py_proc, runner): """) with qtbot.waitSignal(runner.got_cmd, timeout=10000) as blocker: - runner.run(cmd, *args, env=env) + runner.prepare_run(cmd, *args, env=env) + runner.store_html('') + runner.store_text('') data = blocker.args[0] ret_env = json.loads(data) @@ -108,20 +112,16 @@ def test_custom_env(qtbot, monkeypatch, py_proc, runner): assert 'QUTEBROWSER_TEST_2' in ret_env -def test_temporary_files(qtbot, tmpdir, py_proc, runner): - """Make sure temporary files are passed and cleaned up correctly.""" - text_file = tmpdir / 'text' - text_file.write('This is text') - html_file = tmpdir / 'html' - html_file.write('This is HTML') - - env = {'QUTE_TEXT': str(text_file), 'QUTE_HTML': str(html_file)} - +def test_source(qtbot, py_proc, runner): + """Make sure the page source is read and cleaned up correctly.""" cmd, args = py_proc(r""" import os import json - data = {'html': None, 'text': None} + data = { + 'html_file': os.environ['QUTE_HTML'], + 'text_file': os.environ['QUTE_TEXT'], + } with open(os.environ['QUTE_HTML'], 'r') as f: data['html'] = f.read() @@ -136,76 +136,85 @@ def test_temporary_files(qtbot, tmpdir, py_proc, runner): with qtbot.waitSignal(runner.finished, timeout=10000): with qtbot.waitSignal(runner.got_cmd, timeout=10000) as blocker: - runner.run(cmd, *args, env=env) + runner.prepare_run(cmd, *args) + runner.store_html('This is HTML') + runner.store_text('This is text') data = blocker.args[0] parsed = json.loads(data) assert parsed['text'] == 'This is text' assert parsed['html'] == 'This is HTML' - assert not text_file.exists() - assert not html_file.exists() + assert not os.path.exists(parsed['text_file']) + assert not os.path.exists(parsed['html_file']) -def test_command_with_error(qtbot, tmpdir, py_proc, runner): - text_file = tmpdir / 'text' - text_file.write('This is text') - - env = {'QUTE_TEXT': str(text_file)} +def test_command_with_error(qtbot, py_proc, runner): cmd, args = py_proc(r""" - import sys + import sys, os, json + + with open(os.environ['QUTE_FIFO'], 'w') as f: + json.dump(os.environ['QUTE_TEXT'], f) + f.write('\n') + sys.exit(1) """) with qtbot.waitSignal(runner.finished, timeout=10000): - runner.run(cmd, *args, env=env) + with qtbot.waitSignal(runner.got_cmd, timeout=10000) as blocker: + runner.prepare_run(cmd, *args) + runner.store_text('Hello World') + runner.store_html('') - assert not text_file.exists() + data = json.loads(blocker.args[0]) + assert not os.path.exists(data) def test_killed_command(qtbot, tmpdir, py_proc, runner): - text_file = tmpdir / 'text' - text_file.write('This is text') - - pidfile = tmpdir / 'pid' + data_file = tmpdir / 'data' watcher = QFileSystemWatcher() watcher.addPath(str(tmpdir)) - env = {'QUTE_TEXT': str(text_file)} cmd, args = py_proc(r""" import os import time import sys + import json + + data = { + 'pid': os.getpid(), + 'text_file': os.environ['QUTE_TEXT'], + } # We can't use QUTE_FIFO to transmit the PID because that wouldn't work # on Windows, where QUTE_FIFO is only monitored after the script has # exited. with open(sys.argv[1], 'w') as f: - f.write(str(os.getpid())) + json.dump(data, f) time.sleep(30) """) - args.append(str(pidfile)) + args.append(str(data_file)) with qtbot.waitSignal(watcher.directoryChanged, timeout=10000): - runner.run(cmd, *args, env=env) + runner.prepare_run(cmd, *args) + runner.store_text('Hello World') + runner.store_html('') # Make sure the PID was written to the file, not just the file created time.sleep(0.5) + data = json.load(data_file) + with qtbot.waitSignal(runner.finished): - os.kill(int(pidfile.read()), signal.SIGTERM) + os.kill(int(data['pid']), signal.SIGTERM) - assert not text_file.exists() + assert not os.path.exists(data['text_file']) -def test_temporary_files_failed_cleanup(caplog, qtbot, tmpdir, py_proc, - runner): +def test_temporary_files_failed_cleanup(caplog, qtbot, py_proc, runner): """Delete a temporary file from the script so cleanup fails.""" - test_file = tmpdir / 'test' - test_file.write('foo') - cmd, args = py_proc(r""" import os os.remove(os.environ['QUTE_HTML']) @@ -213,41 +222,18 @@ def test_temporary_files_failed_cleanup(caplog, qtbot, tmpdir, py_proc, with caplog.at_level(logging.ERROR): with qtbot.waitSignal(runner.finished, timeout=10000): - runner.run(cmd, *args, env={'QUTE_HTML': str(test_file)}) + runner.prepare_run(cmd, *args) + runner.store_text('') + runner.store_html('') assert len(caplog.records) == 1 - expected = "Failed to delete tempfile {} (".format(test_file) + expected = "Failed to delete tempfile" assert caplog.records[0].message.startswith(expected) -def test_dummy_runner(qtbot): - runner = userscripts._DummyUserscriptRunner(0) - with pytest.raises(cmdexc.CommandError): - with qtbot.waitSignal(runner.finished): - runner.run('cmd', 'arg') - - -def test_store_source_none(): - assert userscripts.store_source(None) == {} - - -def test_store_source(stubs): - expected_text = 'This is text' - expected_html = 'This is HTML' - - frame = stubs.FakeWebFrame(plaintext=expected_text, html=expected_html) - env = userscripts.store_source(frame) - - with open(env['QUTE_TEXT'], 'r', encoding='utf-8') as f: - text = f.read() - with open(env['QUTE_HTML'], 'r', encoding='utf-8') as f: - html = f.read() - - os.remove(env['QUTE_TEXT']) - os.remove(env['QUTE_HTML']) - - assert set(env.keys()) == {'QUTE_TEXT', 'QUTE_HTML'} - assert text == expected_text - assert html == expected_html - assert env['QUTE_TEXT'].endswith('.txt') - assert env['QUTE_HTML'].endswith('.html') +def test_unsupported(monkeypatch, tabbed_browser_stubs): + monkeypatch.setattr(userscripts.os, 'name', 'toaster') + with pytest.raises(userscripts.UnsupportedError) as excinfo: + userscripts.run_async(tab=None, cmd=None, win_id=0, env=None) + expected = "Userscripts are not supported on this platform!" + assert str(excinfo.value) == expected diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 064ed9533..bcc0f731c 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -304,15 +304,15 @@ def test_session_completion(session_manager_stub): ] -def test_tab_completion(stubs, qtbot, app_stub, win_registry, +def test_tab_completion(fake_web_tab, app_stub, win_registry, tabbed_browser_stubs): tabbed_browser_stubs[0].tabs = [ - stubs.FakeWebView(QUrl('https://github.com'), 'GitHub', 0), - stubs.FakeWebView(QUrl('https://wikipedia.org'), 'Wikipedia', 1), - stubs.FakeWebView(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) + fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), + fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), + fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2), ] tabbed_browser_stubs[1].tabs = [ - stubs.FakeWebView(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), + fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] actual = _get_completions(miscmodels.TabCompletionModel()) assert actual == [ @@ -327,16 +327,16 @@ def test_tab_completion(stubs, qtbot, app_stub, win_registry, ] -def test_tab_completion_delete(stubs, qtbot, app_stub, win_registry, +def test_tab_completion_delete(fake_web_tab, qtbot, app_stub, win_registry, tabbed_browser_stubs): """Verify closing a tab by deleting it from the completion widget.""" tabbed_browser_stubs[0].tabs = [ - stubs.FakeWebView(QUrl('https://github.com'), 'GitHub', 0), - stubs.FakeWebView(QUrl('https://wikipedia.org'), 'Wikipedia', 1), - stubs.FakeWebView(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) + fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), + fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), + fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) ] tabbed_browser_stubs[1].tabs = [ - stubs.FakeWebView(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), + fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] model = miscmodels.TabCompletionModel() view = _mock_view_index(model, 0, 1, qtbot) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index a53c2c4d1..9bd7e2a25 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -21,8 +21,6 @@ import os import os.path import configparser -import types -import argparse import collections import shutil from unittest import mock @@ -339,14 +337,18 @@ class TestConfigInit: """Test initializing of the config.""" @pytest.yield_fixture(autouse=True) - def patch(self): + def patch(self, fake_args): objreg.register('app', QObject()) objreg.register('save-manager', mock.MagicMock()) - args = argparse.Namespace(relaxed_config=False) - objreg.register('args', args) + fake_args.relaxed_config = False old_standarddir_args = standarddir._args yield - objreg.global_registry.clear() + objreg.delete('app') + objreg.delete('save-manager') + # registered by config.init() + objreg.delete('config') + objreg.delete('key-config') + objreg.delete('state-config') standarddir._args = old_standarddir_args @pytest.fixture @@ -361,12 +363,14 @@ class TestConfigInit: } return env - def test_config_none(self, monkeypatch, env): + def test_config_none(self, monkeypatch, env, fake_args): """Test initializing with config path set to None.""" - args = types.SimpleNamespace(confdir='', datadir='', cachedir='', - basedir=None) + fake_args.confdir = '' + fake_args.datadir = '' + fake_args.cachedir = '' + fake_args.basedir = None for k, v in env.items(): monkeypatch.setenv(k, v) - standarddir.init(args) + standarddir.init(fake_args) config.init() assert not os.listdir(env['XDG_CONFIG_HOME']) diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index b2cd90963..4d89b4671 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -19,7 +19,6 @@ import pytest -from qutebrowser.keyinput import modeman as modeman_module from qutebrowser.utils import usertypes from PyQt5.QtCore import Qt, QObject, pyqtSignal @@ -40,11 +39,9 @@ class FakeKeyparser(QObject): @pytest.fixture -def modeman(config_stub, qapp): - config_stub.data = {'input': {'forward-unbound-keys': 'auto'}} - mm = modeman_module.ModeManager(0) - mm.register(usertypes.KeyMode.normal, FakeKeyparser()) - return mm +def modeman(mode_manager): + mode_manager.register(usertypes.KeyMode.normal, FakeKeyparser()) + return mode_manager @pytest.mark.parametrize('key, modifiers, text, filtered', [ diff --git a/tests/unit/mainwindow/statusbar/test_percentage.py b/tests/unit/mainwindow/statusbar/test_percentage.py index c40603188..0974e34ed 100644 --- a/tests/unit/mainwindow/statusbar/test_percentage.py +++ b/tests/unit/mainwindow/statusbar/test_percentage.py @@ -20,16 +20,11 @@ """Test Percentage widget.""" -import collections - import pytest from qutebrowser.mainwindow.statusbar.percentage import Percentage -FakeTab = collections.namedtuple('FakeTab', 'scroll_pos') - - @pytest.fixture def percentage(qtbot): """Fixture providing a Percentage widget.""" @@ -44,6 +39,7 @@ def percentage(qtbot): (75, '[75%]'), (25, '[25%]'), (5, '[ 5%]'), + (None, '[???]'), ]) def test_percentage_text(percentage, y, expected): """Test text displayed by the widget based on the y position of a page. @@ -57,9 +53,9 @@ def test_percentage_text(percentage, y, expected): assert percentage.text() == expected -def test_tab_change(percentage): +def test_tab_change(percentage, fake_web_tab): """Make sure the percentage gets changed correctly when switching tabs.""" percentage.set_perc(x=None, y=10) - tab = FakeTab([0, 20]) + tab = fake_web_tab(scroll_pos_perc=(0, 20)) percentage.on_tab_changed(tab) assert percentage.text() == '[20%]' diff --git a/tests/unit/mainwindow/statusbar/test_progress.py b/tests/unit/mainwindow/statusbar/test_progress.py index b90c0814c..5696b269c 100644 --- a/tests/unit/mainwindow/statusbar/test_progress.py +++ b/tests/unit/mainwindow/statusbar/test_progress.py @@ -20,12 +20,10 @@ """Test Progress widget.""" -from collections import namedtuple - import pytest -from qutebrowser.browser.webkit import webview from qutebrowser.mainwindow.statusbar.progress import Progress +from qutebrowser.utils import usertypes @pytest.fixture @@ -55,28 +53,24 @@ def test_load_started(progress_widget): assert progress_widget.isVisible() -# mock tab object -Tab = namedtuple('Tab', 'progress load_status') - - -@pytest.mark.parametrize('tab, expected_visible', [ - (Tab(15, webview.LoadStatus.loading), True), - (Tab(100, webview.LoadStatus.success), False), - (Tab(100, webview.LoadStatus.error), False), - (Tab(100, webview.LoadStatus.warn), False), - (Tab(100, webview.LoadStatus.none), False), +@pytest.mark.parametrize('progress, load_status, expected_visible', [ + (15, usertypes.LoadStatus.loading, True), + (100, usertypes.LoadStatus.success, False), + (100, usertypes.LoadStatus.error, False), + (100, usertypes.LoadStatus.warn, False), + (100, usertypes.LoadStatus.none, False), ]) -def test_tab_changed(progress_widget, tab, expected_visible): +def test_tab_changed(fake_web_tab, progress_widget, progress, load_status, + expected_visible): """Test that progress widget value and visibility state match expectations. - This uses a dummy Tab object. - Args: progress_widget: Progress widget that will be tested. """ + tab = fake_web_tab(progress=progress, load_status=load_status) progress_widget.on_tab_changed(tab) actual = progress_widget.value(), progress_widget.isVisible() - expected = tab.progress, expected_visible + expected = tab.progress(), expected_visible assert actual == expected diff --git a/tests/unit/mainwindow/statusbar/test_url.py b/tests/unit/mainwindow/statusbar/test_url.py index 67c0dea7a..b147d7ab2 100644 --- a/tests/unit/mainwindow/statusbar/test_url.py +++ b/tests/unit/mainwindow/statusbar/test_url.py @@ -21,18 +21,11 @@ """Test Statusbar url.""" import pytest -import collections -from qutebrowser.browser.webkit import webview +from qutebrowser.utils import usertypes from qutebrowser.mainwindow.statusbar import url - -@pytest.fixture -def tab_widget(): - """Fixture providing a fake tab widget.""" - tab = collections.namedtuple('Tab', 'cur_url load_status') - tab.cur_url = collections.namedtuple('cur_url', 'toDisplayString') - return tab +from PyQt5.QtCore import QUrl @pytest.fixture @@ -73,14 +66,14 @@ def test_set_url(url_widget, url_text): assert url_widget.text() == "" -@pytest.mark.parametrize('url_text, title, text', [ - ('http://abc123.com/this/awesome/url.html', 'Awesome site', 'click me!'), - ('https://supersecret.gov/nsa/files.txt', 'Secret area', None), - (None, None, 'did I break?!') +@pytest.mark.parametrize('url_text', [ + 'http://abc123.com/this/awesome/url.html', + 'https://supersecret.gov/nsa/files.txt', + None, ]) -def test_set_hover_url(url_widget, url_text, title, text): +def test_set_hover_url(url_widget, url_text): """Test text when hovering over a link.""" - url_widget.set_hover_url(url_text, title, text) + url_widget.set_hover_url(url_text) if url_text is not None: assert url_widget.text() == url_text assert url_widget._urltype == url.UrlType.hover @@ -99,18 +92,18 @@ def test_set_hover_url(url_widget, url_text, title, text): ]) def test_set_hover_url_encoded(url_widget, url_text, expected): """Test text when hovering over a percent encoded link.""" - url_widget.set_hover_url(url_text, 'title', 'text') + url_widget.set_hover_url(url_text) assert url_widget.text() == expected assert url_widget._urltype == url.UrlType.hover @pytest.mark.parametrize('status, expected', [ - (webview.LoadStatus.success, url.UrlType.success), - (webview.LoadStatus.success_https, url.UrlType.success_https), - (webview.LoadStatus.error, url.UrlType.error), - (webview.LoadStatus.warn, url.UrlType.warn), - (webview.LoadStatus.loading, url.UrlType.normal), - (webview.LoadStatus.none, url.UrlType.normal) + (usertypes.LoadStatus.success, url.UrlType.success), + (usertypes.LoadStatus.success_https, url.UrlType.success_https), + (usertypes.LoadStatus.error, url.UrlType.error), + (usertypes.LoadStatus.warn, url.UrlType.warn), + (usertypes.LoadStatus.loading, url.UrlType.normal), + (usertypes.LoadStatus.none, url.UrlType.normal) ]) def test_on_load_status_changed(url_widget, status, expected): """Test text when status is changed.""" @@ -119,42 +112,36 @@ def test_on_load_status_changed(url_widget, status, expected): assert url_widget._urltype == expected -@pytest.mark.parametrize('load_status, url_text', [ - (url.UrlType.success, 'http://abc123.com/this/awesome/url.html'), - (url.UrlType.success, 'http://reddit.com/r/linux'), - (url.UrlType.success_https, 'www.google.com'), - (url.UrlType.success_https, 'https://supersecret.gov/nsa/files.txt'), - (url.UrlType.warn, 'www.shadysite.org/some/path/to/file/with/issues.htm'), - (url.UrlType.error, 'Th1$ i$ n0t @ n0rm@L uRL! P@n1c! <-->'), - (url.UrlType.error, None) +@pytest.mark.parametrize('load_status, qurl', [ + (url.UrlType.success, QUrl('http://abc123.com/this/awesome/url.html')), + (url.UrlType.success, QUrl('http://reddit.com/r/linux')), + (url.UrlType.success_https, QUrl('www.google.com')), + (url.UrlType.success_https, QUrl('https://supersecret.gov/nsa/files.txt')), + (url.UrlType.warn, QUrl('www.shadysite.org/some/file/with/issues.htm')), + (url.UrlType.error, QUrl('invalid::/url')), ]) -def test_on_tab_changed(url_widget, tab_widget, load_status, url_text): - tab_widget.load_status = load_status - tab_widget.cur_url.toDisplayString = lambda: url_text +def test_on_tab_changed(url_widget, fake_web_tab, load_status, qurl): + tab_widget = fake_web_tab(load_status=load_status, url=qurl) url_widget.on_tab_changed(tab_widget) - if url_text is not None: - assert url_widget._urltype == load_status - assert url_widget.text() == url_text - else: - assert url_widget._urltype == url.UrlType.normal - assert url_widget.text() == '' + assert url_widget._urltype == load_status + assert url_widget.text() == qurl.toDisplayString() @pytest.mark.parametrize('url_text, load_status, expected_status', [ - ('http://abc123.com/this/awesome/url.html', webview.LoadStatus.success, + ('http://abc123.com/this/awesome/url.html', usertypes.LoadStatus.success, url.UrlType.success), - ('https://supersecret.gov/nsa/files.txt', webview.LoadStatus.success_https, + ('https://supersecret.gov/nsa/files.txt', usertypes.LoadStatus.success_https, url.UrlType.success_https), - ('Th1$ i$ n0t @ n0rm@L uRL! P@n1c! <-->', webview.LoadStatus.error, + ('Th1$ i$ n0t @ n0rm@L uRL! P@n1c! <-->', usertypes.LoadStatus.error, url.UrlType.error), - ('http://www.qutebrowser.org/CONTRIBUTING.html', webview.LoadStatus.loading, + ('http://www.qutebrowser.org/CONTRIBUTING.html', usertypes.LoadStatus.loading, url.UrlType.normal), - ('www.whatisthisurl.com', webview.LoadStatus.warn, url.UrlType.warn) + ('www.whatisthisurl.com', usertypes.LoadStatus.warn, url.UrlType.warn) ]) def test_normal_url(url_widget, url_text, load_status, expected_status): url_widget.set_url(url_text) url_widget.on_load_status_changed(load_status.name) - url_widget.set_hover_url(url_text, "", "") - url_widget.set_hover_url("", "", "") + url_widget.set_hover_url(url_text) + url_widget.set_hover_url("") assert url_widget.text() == url_text assert url_widget._urltype == expected_status diff --git a/tests/unit/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py index e5d8554c7..00f8b290d 100644 --- a/tests/unit/mainwindow/test_tabwidget.py +++ b/tests/unit/mainwindow/test_tabwidget.py @@ -61,7 +61,7 @@ class TestTabWidget: qtbot.addWidget(w) return w - def test_small_icon_doesnt_crash(self, widget, qtbot, stubs): + def test_small_icon_doesnt_crash(self, widget, qtbot, fake_web_tab): """Test that setting a small icon doesn't produce a crash. Regression test for #1015. @@ -69,7 +69,7 @@ class TestTabWidget: # Size taken from issue report pixmap = QPixmap(72, 1) icon = QIcon(pixmap) - page = stubs.FakeWebView() - widget.addTab(page, icon, 'foobar') + tab = fake_web_tab() + widget.addTab(tab, icon, 'foobar') widget.show() qtbot.waitForWindowShown(widget) diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index e71bd4105..faf23edf0 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -42,9 +42,6 @@ from qutebrowser.utils import objreg, qtutils from helpers import stubs -Args = collections.namedtuple('Args', 'basedir') - - pytestmark = pytest.mark.usefixtures('qapp') diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py index d1f60aef4..a0432f65a 100644 --- a/tests/unit/misc/test_sessions.py +++ b/tests/unit/misc/test_sessions.py @@ -38,6 +38,10 @@ from qutebrowser.commands import cmdexc pytestmark = pytest.mark.qt_log_ignore('QIODevice::read.*: device not open', extend=True) +webengine_refactoring_xfail = pytest.mark.xfail( + True, reason='Broke during QtWebEngine refactoring, will be fixed after ' + 'sessions are refactored too.') + @pytest.fixture def sess_man(): @@ -166,6 +170,7 @@ class HistTester: return ret[0] +@webengine_refactoring_xfail class TestSaveTab: @pytest.fixture @@ -350,6 +355,7 @@ class TestSaveAll: data = sess_man._save_all() assert not data['windows'] + @webengine_refactoring_xfail def test_normal(self, fake_windows, sess_man): """Test with some windows and tabs set up.""" data = sess_man._save_all() @@ -372,6 +378,7 @@ class TestSaveAll: expected = {'windows': [win1, win2]} assert data == expected + @webengine_refactoring_xfail def test_no_active_window(self, sess_man, fake_windows, stubs, monkeypatch): qapp = stubs.FakeQApplication(active_window=None) @@ -491,6 +498,7 @@ class TestSave: sess_man.save(str(session_path), load_next_time=True) assert state_config['general']['session'] == str(session_path) + @webengine_refactoring_xfail def test_utf_8_valid(self, tmpdir, sess_man, fake_history): """Make sure data containing valid UTF8 gets saved correctly.""" session_path = tmpdir / 'foo.yml' @@ -502,6 +510,7 @@ class TestSave: data = session_path.read_text('utf-8') assert 'title: foo☃bar' in data + @webengine_refactoring_xfail def test_utf_8_invalid(self, tmpdir, sess_man, fake_history): """Make sure data containing invalid UTF8 raises SessionError.""" session_path = tmpdir / 'foo.yml' @@ -528,6 +537,7 @@ class TestSave: @pytest.mark.skipif( os.name == 'nt', reason="Test segfaults on Windows, see " "https://github.com/The-Compiler/qutebrowser/issues/895") + @webengine_refactoring_xfail def test_long_output(self, fake_windows, tmpdir, sess_man): session_path = tmpdir / 'foo.yml' @@ -628,6 +638,7 @@ def fake_webview(): return FakeWebView() +@webengine_refactoring_xfail class TestLoadTab: def test_no_history(self, sess_man, fake_webview): @@ -728,6 +739,7 @@ class TestListSessions: class TestSessionSave: + @webengine_refactoring_xfail def test_normal_save(self, sess_man, tmpdir, fake_windows): sess_file = tmpdir / 'foo.yml' sess_man.session_save(0, str(sess_file), quiet=True) @@ -743,6 +755,7 @@ class TestSessionSave: assert str(excinfo.value) == expected_text assert not (tmpdir / '_foo.yml').exists() + @webengine_refactoring_xfail def test_internal_with_force(self, tmpdir, fake_windows): sess_man = sessions.SessionManager(str(tmpdir)) sess_man.session_save(0, '_foo', force=True, quiet=True) @@ -756,6 +769,7 @@ class TestSessionSave: assert str(excinfo.value) == "No session loaded currently!" + @webengine_refactoring_xfail def test_current_set(self, tmpdir, fake_windows): sess_man = sessions.SessionManager(str(tmpdir)) sess_man._current = 'foo' @@ -768,6 +782,7 @@ class TestSessionSave: assert str(excinfo.value).startswith('Error while saving session: ') + @webengine_refactoring_xfail def test_message(self, sess_man, tmpdir, message_mock, fake_windows): message_mock.patch('qutebrowser.misc.sessions.message') sess_path = str(tmpdir / 'foo.yml') @@ -775,6 +790,7 @@ class TestSessionSave: expected_text = 'Saved session {}.'.format(sess_path) assert message_mock.getmsg(immediate=True).text == expected_text + @webengine_refactoring_xfail def test_message_quiet(self, sess_man, tmpdir, message_mock, fake_windows): message_mock.patch('qutebrowser.misc.sessions.message') sess_path = str(tmpdir / 'foo.yml') diff --git a/tests/unit/utils/test_error.py b/tests/unit/utils/test_error.py index 4a8c1ccbe..d3f437b10 100644 --- a/tests/unit/utils/test_error.py +++ b/tests/unit/utils/test_error.py @@ -19,7 +19,6 @@ """Tests for qutebrowser.utils.error.""" import sys -import collections import logging import pytest @@ -31,9 +30,6 @@ from PyQt5.QtCore import pyqtSlot, QTimer from PyQt5.QtWidgets import QMessageBox -Args = collections.namedtuple('Args', 'no_err_windows') - - class Error(Exception): pass @@ -47,14 +43,15 @@ class Error(Exception): (ipc.Error, 'misc.ipc.Error', 'none'), (Error, 'test_error.Error', 'none'), ]) -def test_no_err_windows(caplog, exc, name, exc_text): +def test_no_err_windows(caplog, exc, name, exc_text, fake_args): """Test handle_fatal_exc with no_err_windows = True.""" + fake_args.no_err_windows = True try: raise exc except Exception as e: with caplog.at_level(logging.ERROR): - error.handle_fatal_exc(e, Args(no_err_windows=True), 'title', - pre_text='pre', post_text='post') + error.handle_fatal_exc(e, fake_args, 'title', pre_text='pre', + post_text='post') assert len(caplog.records) == 1 @@ -82,7 +79,7 @@ def test_no_err_windows(caplog, exc, name, exc_text): ('foo', 'bar', 'foo: exception\n\nbar'), ('', 'bar', 'exception\n\nbar'), ], ids=repr) -def test_err_windows(qtbot, qapp, pre_text, post_text, expected): +def test_err_windows(qtbot, qapp, fake_args, pre_text, post_text, expected): @pyqtSlot() def err_window_check(): @@ -97,6 +94,7 @@ def test_err_windows(qtbot, qapp, pre_text, post_text, expected): finally: w.close() + fake_args.no_err_windows = False QTimer.singleShot(0, err_window_check) - error.handle_fatal_exc(ValueError("exception"), Args(no_err_windows=False), - 'title', pre_text=pre_text, post_text=post_text) + error.handle_fatal_exc(ValueError("exception"), fake_args, 'title', + pre_text=pre_text, post_text=post_text) diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py index 27332efdf..5554717d9 100644 --- a/tests/unit/utils/test_log.py +++ b/tests/unit/utils/test_log.py @@ -267,3 +267,14 @@ class TestHideQtWarning: with caplog.at_level(logging.WARNING, 'qt-tests'): logger.warning(" Hello World ") assert not caplog.records + + +@pytest.mark.parametrize('suffix, expected', [ + ('', 'STUB: test_stub'), + ('foo', 'STUB: test_stub (foo)'), +]) +def test_stub(caplog, suffix, expected): + with caplog.at_level(logging.WARNING, 'misc'): + log.stub(suffix) + assert len(caplog.records) == 1 + assert caplog.records[0].message == expected