summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Bruhin <me@the-compiler.org>2020-06-23 12:43:34 +0200
committerFlorian Bruhin <me@the-compiler.org>2020-06-23 12:43:34 +0200
commit3a5afadf5a80e16597c0601667ee90966bba277f (patch)
tree0c69e1fc066a59abcfb1795c36abfcbe4e1acdbb
parentac1539f3527de8400df2ac6377c38514450d31b0 (diff)
parent370bd12a1512f5164be0e3ae1838515957e587fb (diff)
downloadqutebrowser-3a5afadf5a80e16597c0601667ee90966bba277f.tar.gz
qutebrowser-3a5afadf5a80e16597c0601667ee90966bba277f.zip
Merge branch 'docked-inspector'
-rw-r--r--.mypy.ini10
-rw-r--r--doc/help/commands.asciidoc18
-rw-r--r--doc/help/settings.asciidoc9
-rw-r--r--doc/qutebrowser.1.asciidoc2
-rw-r--r--qutebrowser/api/apitypes.py2
-rw-r--r--qutebrowser/browser/browsertab.py29
-rw-r--r--qutebrowser/browser/commands.py26
-rw-r--r--qutebrowser/browser/eventfilter.py61
-rw-r--r--qutebrowser/browser/inspector.py179
-rw-r--r--qutebrowser/browser/webengine/webengineinspector.py114
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py8
-rw-r--r--qutebrowser/browser/webengine/webview.py4
-rw-r--r--qutebrowser/browser/webkit/webkitinspector.py12
-rw-r--r--qutebrowser/completion/completionwidget.py10
-rw-r--r--qutebrowser/completion/models/miscmodels.py25
-rw-r--r--qutebrowser/components/misccommands.py27
-rw-r--r--qutebrowser/config/configdata.yml9
-rw-r--r--qutebrowser/config/configfiles.py12
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py28
-rw-r--r--qutebrowser/misc/miscwidgets.py159
-rw-r--r--qutebrowser/qutebrowser.py14
-rw-r--r--tests/helpers/fixtures.py23
-rw-r--r--tests/unit/browser/test_inspector.py153
-rw-r--r--tests/unit/config/test_configfiles.py8
-rw-r--r--tests/unit/misc/test_miscwidgets.py151
-rw-r--r--tests/unit/misc/test_sessions.py6
26 files changed, 919 insertions, 180 deletions
diff --git a/.mypy.ini b/.mypy.ini
index 98150f002..1972e5040 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -69,6 +69,16 @@ disallow_untyped_defs = True
[mypy-qutebrowser.browser.hints]
disallow_untyped_defs = True
+[mypy-qutebrowser.browser.inspector]
+disallow_untyped_defs = True
+
+[mypy-qutebrowser.browser.webkit.webkitinspector]
+disallow_untyped_defs = True
+
+[mypy-qutebrowser.browser.webengine.webengineinspector]
+disallow_untyped_defs = True
+disallow_incomplete_defs = True
+
[mypy-qutebrowser.misc.objects]
disallow_untyped_defs = True
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index 8d70b19c8..017f4b20b 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -51,6 +51,7 @@ possible to run or bind multiple commands by separating them with `;;`.
|<<config-source,config-source>>|Read a config.py file.
|<<config-unset,config-unset>>|Unset an option.
|<<config-write-py,config-write-py>>|Write the current configuration to a config.py file.
+|<<devtools,devtools>>|Toggle the developer tools (web inspector).
|<<download,download>>|Download a given URL, or current page if no URL given.
|<<download-cancel,download-cancel>>|Cancel the last/[count]th download.
|<<download-clear,download-clear>>|Remove all finished downloads from the list.
@@ -72,7 +73,6 @@ possible to run or bind multiple commands by separating them with `;;`.
|<<history-clear,history-clear>>|Clear all browsing history.
|<<home,home>>|Open main startpage in current tab.
|<<insert-text,insert-text>>|Insert text at cursor position.
-|<<inspector,inspector>>|Toggle the web inspector.
|<<jseval,jseval>>|Evaluate a JavaScript string.
|<<jump-mark,jump-mark>>|Jump to the mark named by `key`.
|<<later,later>>|Execute a command after some time.
@@ -413,6 +413,16 @@ Write the current configuration to a config.py file.
* +*-f*+, +*--force*+: Force overwriting existing files.
* +*-d*+, +*--defaults*+: Write the defaults instead of values configured via :set.
+[[devtools]]
+=== devtools
+Syntax: +:devtools ['position']+
+
+Toggle the developer tools (web inspector).
+
+==== positional arguments
+* +'position'+: Where to open the devtools (right/left/top/bottom/window).
+
+
[[download]]
=== download
Syntax: +:download [*--mhtml*] [*--dest* 'dest'] ['url']+
@@ -725,12 +735,6 @@ Insert text at cursor position.
==== note
* This command does not split arguments after the last argument and handles quotes literally.
-[[inspector]]
-=== inspector
-Toggle the web inspector.
-
-Note: Due to a bug in Qt, the inspector will show incorrect request headers in the network tab.
-
[[jseval]]
=== jseval
Syntax: +:jseval [*--file*] [*--quiet*] [*--world* 'world'] 'js-code'+
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 0b4f3d421..6fac9d311 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -681,12 +681,17 @@ Default:
* +pass:[u]+: +pass:[undo]+
* +pass:[v]+: +pass:[enter-mode caret]+
* +pass:[wB]+: +pass:[set-cmd-text -s :bookmark-load -w]+
+* +pass:[wIh]+: +pass:[devtools left]+
+* +pass:[wIj]+: +pass:[devtools bottom]+
+* +pass:[wIk]+: +pass:[devtools top]+
+* +pass:[wIl]+: +pass:[devtools right]+
+* +pass:[wIw]+: +pass:[devtools window]+
* +pass:[wO]+: +pass:[set-cmd-text :open -w {url:pretty}]+
* +pass:[wP]+: +pass:[open -w -- {primary}]+
* +pass:[wb]+: +pass:[set-cmd-text -s :quickmark-load -w]+
* +pass:[wf]+: +pass:[hint all window]+
* +pass:[wh]+: +pass:[back -w]+
-* +pass:[wi]+: +pass:[inspector]+
+* +pass:[wi]+: +pass:[devtools]+
* +pass:[wl]+: +pass:[forward -w]+
* +pass:[wo]+: +pass:[set-cmd-text -s :open -w]+
* +pass:[wp]+: +pass:[open -w -- {clipboard}]+
@@ -2570,7 +2575,7 @@ On QtWebKit, this setting is unavailable.
[[content.xss_auditing]]
=== content.xss_auditing
Monitor load requests for cross-site scripting attempts.
-Suspicious scripts will be blocked and reported in the inspector's JavaScript console.
+Suspicious scripts will be blocked and reported in the devtools JavaScript console.
Note that bypasses for the XSS auditor are widely known and it can be abused for cross-site info leaks in some scenarios, see: https://www.chromium.org/developers/design-documents/xss-auditor
This setting supports URL patterns.
diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc
index 8dae3eaef..ad8a43dbe 100644
--- a/doc/qutebrowser.1.asciidoc
+++ b/doc/qutebrowser.1.asciidoc
@@ -63,7 +63,7 @@ show it.
Which backend to use.
*--enable-webengine-inspector*::
- Enable the web inspector for QtWebEngine. Note that this is a SECURITY RISK and you should not visit untrusted websites with the inspector turned on. See https://bugreports.qt.io/browse/QTBUG-50725 for more details. This is not needed anymore since Qt 5.11 where the inspector is always enabled and secure.
+ Enable the web inspector / devtools for QtWebEngine. Note that this is a SECURITY RISK and you should not visit untrusted websites with the inspector turned on. See https://bugreports.qt.io/browse/QTBUG-50725 for more details. This is not needed anymore since Qt 5.11 where the inspector is always enabled and secure.
=== debug arguments
*-l* '{critical,error,warning,info,debug,vdebug}', *--loglevel* '{critical,error,warning,info,debug,vdebug}'::
diff --git a/qutebrowser/api/apitypes.py b/qutebrowser/api/apitypes.py
index 1019c9132..f3aa969d8 100644
--- a/qutebrowser/api/apitypes.py
+++ b/qutebrowser/api/apitypes.py
@@ -21,6 +21,8 @@
# pylint: disable=unused-import
from qutebrowser.browser.browsertab import WebTabError, AbstractTab as Tab
+from qutebrowser.browser.inspector import (Position as InspectorPosition,
+ Error as InspectorError)
from qutebrowser.browser.webelem import (Error as WebElemError,
AbstractWebElement as WebElement)
from qutebrowser.utils.usertypes import ClickTarget, JsWorld
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index b42ee1dac..b847986e2 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -45,7 +45,7 @@ from qutebrowser.config import config
from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils,
urlutils, message)
from qutebrowser.misc import miscwidgets, objects, sessions
-from qutebrowser.browser import eventfilter
+from qutebrowser.browser import eventfilter, inspector
from qutebrowser.qt import sip
if typing.TYPE_CHECKING:
@@ -124,6 +124,7 @@ class TabData:
fullscreen: Whether the tab has a video shown fullscreen currently.
netrc_used: Whether netrc authentication was performed.
input_mode: current input mode for the tab.
+ splitter: InspectorSplitter used to show inspector inside the tab.
"""
keep_icon = attr.ib(False) # type: bool
@@ -138,6 +139,7 @@ class TabData:
netrc_used = attr.ib(False) # type: bool
input_mode = attr.ib(usertypes.KeyMode.normal) # type: usertypes.KeyMode
last_navigation = attr.ib(None) # type: usertypes.NavigationRequest
+ splitter = attr.ib(None) # type: miscwidgets.InspectorSplitter
def should_show_icon(self) -> bool:
return (config.val.tabs.favicons.show == 'always' or
@@ -844,6 +846,28 @@ class AbstractTabPrivate:
"""
raise NotImplementedError
+ def _recreate_inspector(self) -> None:
+ """Recreate the inspector when detached to a window.
+
+ This is needed to circumvent a QtWebEngine bug (which wasn't
+ investigated further) which sometimes results in the window not
+ appearing anymore.
+ """
+ self._tab.data.inspector = None
+ self.toggle_inspector(inspector.Position.window)
+
+ def toggle_inspector(self, position: inspector.Position) -> None:
+ """Show/hide (and if needed, create) the web inspector for this tab."""
+ tabdata = self._tab.data
+ if tabdata.inspector is None:
+ tabdata.inspector = inspector.create(
+ splitter=tabdata.splitter,
+ win_id=self._tab.win_id)
+ self._tab.shutting_down.connect(tabdata.inspector.shutdown)
+ tabdata.inspector.recreate.connect(self._recreate_inspector)
+ tabdata.inspector.inspect(self._widget.page())
+ tabdata.inspector.set_position(position)
+
class AbstractTab(QWidget):
@@ -929,7 +953,8 @@ class AbstractTab(QWidget):
def _set_widget(self, widget: QWidget) -> None:
# pylint: disable=protected-access
self._widget = widget
- self._layout.wrap(self, widget)
+ self.data.splitter = miscwidgets.InspectorSplitter(widget)
+ self._layout.wrap(self, self.data.splitter)
self.history._history = widget.history()
self.history.private_api._history = widget.history()
self.scroller._init_widget(widget)
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index 78ed6c383..1707c398d 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -30,8 +30,8 @@ from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery
from qutebrowser.commands import userscripts, runners
from qutebrowser.api import cmdutils
from qutebrowser.config import config, configdata
-from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate,
- webelem, downloads)
+from qutebrowser.browser import (urlmarks, browsertab, navigate, webelem,
+ downloads)
from qutebrowser.keyinput import modeman, keyutils
from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
objreg, utils, standarddir, debug)
@@ -1240,28 +1240,6 @@ class CommandDispatcher:
raise cmdutils.CommandError("Bookmark '{}' not found!".format(url))
message.info("Removed bookmark {}".format(url))
- @cmdutils.register(instance='command-dispatcher', name='inspector',
- scope='window')
- def toggle_inspector(self):
- """Toggle the web inspector.
-
- Note: Due to a bug in Qt, the inspector will show incorrect request
- headers in the network tab.
- """
- tab = self._current_widget()
- # FIXME:qtwebengine have a proper API for this
- page = tab._widget.page() # pylint: disable=protected-access
-
- try:
- if tab.data.inspector is None:
- tab.data.inspector = inspector.create()
- tab.data.inspector.inspect(page)
- tab.data.inspector.show()
- else:
- tab.data.inspector.toggle(page)
- except inspector.WebInspectorError as e:
- raise cmdutils.CommandError(e)
-
@cmdutils.register(instance='command-dispatcher', scope='window')
def download(self, url=None, *, mhtml_=False, dest=None):
"""Download a given URL, or current page if no URL given.
diff --git a/qutebrowser/browser/eventfilter.py b/qutebrowser/browser/eventfilter.py
index 9e93fd13f..d91502092 100644
--- a/qutebrowser/browser/eventfilter.py
+++ b/qutebrowser/browser/eventfilter.py
@@ -39,42 +39,57 @@ class ChildEventFilter(QObject):
Attributes:
_filter: The event filter to install.
_widget: The widget expected to send out childEvents.
+ _win_id: The window this ChildEventFilter lives in.
+ _focus_workaround: Whether to enable a workaround for QTBUG-68076.
"""
- def __init__(self, eventfilter, widget, win_id, parent=None):
+ def __init__(self, *, eventfilter, win_id, focus_workaround=False,
+ widget=None, parent=None):
super().__init__(parent)
self._filter = eventfilter
- assert widget is not None
self._widget = widget
self._win_id = win_id
+ self._focus_workaround = focus_workaround
+ if focus_workaround:
+ assert widget is not None
+
+ def _do_focus_workaround(self):
+ """WORKAROUND for https://bugreports.qt.io/browse/QTBUG-68076."""
+ if not self._focus_workaround:
+ return
+
+ assert self._widget is not None
+
+ pass_modes = [usertypes.KeyMode.command,
+ usertypes.KeyMode.prompt,
+ usertypes.KeyMode.yesno]
+
+ if modeman.instance(self._win_id).mode in pass_modes:
+ return
+
+ tabbed_browser = objreg.get('tabbed-browser', scope='window',
+ window=self._win_id)
+ current_index = tabbed_browser.widget.currentIndex()
+ try:
+ widget_index = tabbed_browser.widget.indexOf(self._widget.parent())
+ except RuntimeError:
+ widget_index = -1
+ if current_index == widget_index:
+ QTimer.singleShot(0, self._widget.setFocus)
def eventFilter(self, obj, event):
"""Act on ChildAdded events."""
if event.type() == QEvent.ChildAdded:
child = event.child()
- log.misc.debug("{} got new child {}, installing filter".format(
- obj, child))
- assert obj is self._widget
- child.installEventFilter(self._filter)
+ log.misc.debug("{} got new child {}, installing filter"
+ .format(obj, child))
- if qtutils.version_check('5.11', compiled=False, exact=True):
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-68076
- pass_modes = [usertypes.KeyMode.command,
- usertypes.KeyMode.prompt,
- usertypes.KeyMode.yesno]
- if modeman.instance(self._win_id).mode not in pass_modes:
- tabbed_browser = objreg.get('tabbed-browser',
- scope='window',
- window=self._win_id)
- current_index = tabbed_browser.widget.currentIndex()
- try:
- widget_index = tabbed_browser.widget.indexOf(
- self._widget.parent())
- except RuntimeError:
- widget_index = -1
- if current_index == widget_index:
- QTimer.singleShot(0, self._widget.setFocus)
+ # Additional sanity check, but optional
+ if self._widget is not None:
+ assert obj is self._widget
+ child.installEventFilter(self._filter)
+ self._do_focus_workaround()
elif event.type() == QEvent.ChildRemoved:
child = event.child()
log.misc.debug("{}: removed child {}".format(obj, child))
diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py
index e7c8e2a7f..e089438be 100644
--- a/qutebrowser/browser/inspector.py
+++ b/qutebrowser/browser/inspector.py
@@ -22,53 +22,183 @@
import base64
import binascii
import typing
+import enum
from PyQt5.QtWidgets import QWidget
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QEvent
+from PyQt5.QtGui import QCloseEvent
+from qutebrowser.browser import eventfilter
from qutebrowser.config import configfiles
from qutebrowser.utils import log, usertypes
+from qutebrowser.keyinput import modeman
from qutebrowser.misc import miscwidgets, objects
-def create(parent=None):
+def create(*, splitter: 'miscwidgets.InspectorSplitter',
+ win_id: int,
+ parent: QWidget = None) -> 'AbstractWebInspector':
"""Get a WebKitInspector/WebEngineInspector.
Args:
+ splitter: InspectorSplitter where the inspector can be placed.
+ win_id: The window ID this inspector is associated with.
parent: The Qt parent to set.
"""
# Importing modules here so we don't depend on QtWebEngine without the
# argument and to avoid circular imports.
if objects.backend == usertypes.Backend.QtWebEngine:
from qutebrowser.browser.webengine import webengineinspector
- return webengineinspector.WebEngineInspector(parent)
+ if webengineinspector.supports_new():
+ return webengineinspector.WebEngineInspector(
+ splitter, win_id, parent)
+ else:
+ return webengineinspector.LegacyWebEngineInspector(
+ splitter, win_id, parent)
else:
from qutebrowser.browser.webkit import webkitinspector
- return webkitinspector.WebKitInspector(parent)
+ return webkitinspector.WebKitInspector(splitter, win_id, parent)
+
+
+class Position(enum.Enum):
+
+ """Where the inspector is shown."""
+
+ right = 1
+ left = 2
+ top = 3
+ bottom = 4
+ window = 5
-class WebInspectorError(Exception):
+class Error(Exception):
"""Raised when the inspector could not be initialized."""
+class _EventFilter(QObject):
+
+ """Event filter to enter insert mode when inspector was clicked.
+
+ We need to use this with a ChildEventFilter (rather than just overriding
+ mousePressEvent) for two reasons:
+
+ - For QtWebEngine, we need to listen for mouse events on its focusProxy(),
+ which can change when another page loads (which might be possible with an
+ inspector as well?)
+
+ - For QtWebKit, we need to listen for mouse events on the QWebView used by
+ the QWebInspector.
+ """
+
+ def __init__(self, win_id: int, parent: QObject) -> None:
+ super().__init__(parent)
+ self._win_id = win_id
+
+ def eventFilter(self, _obj: QObject, event: QEvent) -> bool:
+ """Enter insert mode if the inspector is clicked."""
+ if event.type() == QEvent.MouseButtonPress:
+ modeman.enter(self._win_id, usertypes.KeyMode.insert,
+ reason='Inspector clicked')
+ return False
+
+
class AbstractWebInspector(QWidget):
- """A customized WebInspector which stores its geometry."""
+ """Base class for QtWebKit/QtWebEngine inspectors.
- def __init__(self, parent=None):
+ Attributes:
+ _position: position of the inspector (right/left/top/bottom/window)
+ _splitter: InspectorSplitter where the inspector can be placed.
+
+ Signals:
+ recreate: Emitted when the inspector should be recreated.
+ """
+
+ recreate = pyqtSignal()
+
+ def __init__(self, splitter: 'miscwidgets.InspectorSplitter',
+ win_id: int,
+ parent: QWidget = None) -> None:
super().__init__(parent)
self._widget = typing.cast(QWidget, None)
self._layout = miscwidgets.WrapperLayout(self)
- self._load_state_geometry()
-
- def _set_widget(self, widget):
+ self._splitter = splitter
+ self._position = None # type: typing.Optional[Position]
+ self._event_filter = _EventFilter(win_id, parent=self)
+ self._child_event_filter = eventfilter.ChildEventFilter(
+ eventfilter=self._event_filter,
+ win_id=win_id,
+ parent=self)
+
+ def _set_widget(self, widget: QWidget) -> None:
self._widget = widget
- self._layout.wrap(self, widget)
+ self._widget.setWindowTitle("Web Inspector")
+ self._widget.installEventFilter(self._child_event_filter)
+ self._layout.wrap(self, self._widget)
- def _load_state_geometry(self):
+ def _load_position(self) -> Position:
+ """Get the last position the inspector was in."""
+ pos = configfiles.state['inspector'].get('position', 'right')
+ return Position[pos]
+
+ def _save_position(self, position: Position) -> None:
+ """Save the last position the inspector was in."""
+ configfiles.state['inspector']['position'] = position.name
+
+ def _needs_recreate(self) -> bool:
+ """Whether the inspector needs recreation when detaching to a window.
+
+ This is done due to an unknown QtWebEngine bug which sometimes prevents
+ inspector windows from showing up.
+
+ Needs to be overridden by subclasses.
+ """
+ return False
+
+ def set_position(self, position: typing.Optional[Position]) -> None:
+ """Set the position of the inspector.
+
+ If the position is None, the last known position is used.
+ """
+ if position is None:
+ position = self._load_position()
+ else:
+ self._save_position(position)
+
+ if position == self._position:
+ self.toggle()
+ return
+
+ if (position == Position.window and
+ self._position is not None and
+ self._needs_recreate()):
+ # Detaching to window
+ self.recreate.emit()
+ self.shutdown()
+ return
+ elif position == Position.window:
+ self.setParent(None) # type: ignore[call-overload]
+ self._load_state_geometry()
+ else:
+ self._splitter.set_inspector(self, position)
+
+ self._position = position
+
+ self._widget.show()
+ self.show()
+
+ def toggle(self) -> None:
+ """Toggle visibility of the inspector."""
+ if self.isVisible():
+ self.hide()
+ else:
+ self.show()
+
+ def _load_state_geometry(self) -> None:
"""Load the geometry from the state file."""
try:
- data = configfiles.state['geometry']['inspector']
+ data = configfiles.state['inspector']['window']
geom = base64.b64decode(data, validate=True)
except KeyError:
# First start
@@ -77,27 +207,22 @@ class AbstractWebInspector(QWidget):
log.misc.exception("Error while reading geometry")
else:
log.init.debug("Loading geometry from {!r}".format(geom))
- ok = self.restoreGeometry(geom)
+ ok = self._widget.restoreGeometry(geom)
if not ok:
log.init.warning("Error while loading geometry.")
- def closeEvent(self, e):
+ def closeEvent(self, _e: QCloseEvent) -> None:
"""Save the geometry when closed."""
- data = self.saveGeometry().data()
+ data = self._widget.saveGeometry().data()
geom = base64.b64encode(data).decode('ASCII')
- configfiles.state['geometry']['inspector'] = geom
+ configfiles.state['inspector']['window'] = geom
- self.inspect(None)
- super().closeEvent(e)
-
- def inspect(self, page):
+ def inspect(self, page: QWidget) -> None:
"""Inspect the given QWeb(Engine)Page."""
raise NotImplementedError
- def toggle(self, page):
- """Show/hide the inspector."""
- if self._widget.isVisible():
- self.hide()
- else:
- self.inspect(page)
- self.show()
+ @pyqtSlot()
+ def shutdown(self) -> None:
+ """Clean up the inspector."""
+ self.close()
+ self.deleteLater()
diff --git a/qutebrowser/browser/webengine/webengineinspector.py b/qutebrowser/browser/webengine/webengineinspector.py
index 73fa65c42..f84415c65 100644
--- a/qutebrowser/browser/webengine/webengineinspector.py
+++ b/qutebrowser/browser/webengine/webengineinspector.py
@@ -20,51 +20,115 @@
"""Customized QWebInspector for QtWebEngine."""
import os
+import typing
+import pathlib
-from PyQt5.QtCore import QUrl
-from PyQt5.QtWebEngineWidgets import QWebEngineView
+from PyQt5.QtCore import QUrl, QLibraryInfo
+from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
+from PyQt5.QtWidgets import QWidget
from qutebrowser.browser import inspector
from qutebrowser.browser.webengine import webenginesettings
+from qutebrowser.misc import miscwidgets
+from qutebrowser.utils import version, qtutils
-class WebEngineInspector(inspector.AbstractWebInspector):
+class WebEngineInspectorView(QWebEngineView):
+
+ """The QWebEngineView used for the inspector.
+
+ We don't use a qutebrowser WebEngineView because that has various
+ customization which doesn't apply to the inspector.
+ """
+
+ def createWindow(self,
+ wintype: QWebEnginePage.WebWindowType) -> QWebEngineView:
+ """Called by Qt when a page wants to create a new tab or window.
+
+ In case the user wants to open a resource in a new tab, we use the
+ createWindow handling of the main page to achieve that.
+
+ See WebEngineView.createWindow for details.
+ """
+ return self.page().inspectedPage().view().createWindow(wintype)
+
+
+def supports_new() -> bool:
+ """Check whether a new-style inspector is supported."""
+ return hasattr(QWebEnginePage, 'setInspectedPage')
+
+
+class LegacyWebEngineInspector(inspector.AbstractWebInspector):
+
+ """A web inspector for QtWebEngine without Qt API support.
- """A web inspector for QtWebEngine."""
+ Only needed with Qt <= 5.10.
+ """
- def __init__(self, parent=None):
- super().__init__(parent)
- self.port = None
- view = QWebEngineView()
+ def __init__(self, splitter: miscwidgets.InspectorSplitter,
+ win_id: int,
+ parent: QWidget = None) -> None:
+ super().__init__(splitter, win_id, parent)
+ self._ensure_enabled()
+ view = WebEngineInspectorView()
self._settings = webenginesettings.WebEngineSettings(view.settings())
self._set_widget(view)
- def _inspect_old(self, page):
- """Set up the inspector for Qt < 5.11."""
- try:
- port = int(os.environ['QTWEBENGINE_REMOTE_DEBUGGING'])
- except KeyError:
- raise inspector.WebInspectorError(
+ def _ensure_enabled(self) -> None:
+ if 'QTWEBENGINE_REMOTE_DEBUGGING' not in os.environ:
+ raise inspector.Error(
"QtWebEngine inspector is not enabled. See "
"'qutebrowser --help' for details.")
+ def inspect(self, page: QWebEnginePage) -> None: # type: ignore[override]
# We're lying about the URL here a bit, but this way, URL patterns for
# Qt 5.11/5.12/5.13 also work in this case.
self._settings.update_for_url(QUrl('chrome-devtools://devtools'))
+ port = int(os.environ['QTWEBENGINE_REMOTE_DEBUGGING'])
+ self._widget.load(QUrl('http://localhost:{}/'.format(port)))
- if page is None:
- self._widget.load(QUrl('about:blank'))
- else:
- self._widget.load(QUrl('http://localhost:{}/'.format(port)))
- def _inspect_new(self, page):
- """Set up the inspector for Qt >= 5.11."""
+class WebEngineInspector(inspector.AbstractWebInspector):
+
+ """A web inspector for QtWebEngine with Qt API support.
+
+ Available since Qt 5.11.
+ """
+
+ def __init__(self, splitter: miscwidgets.InspectorSplitter,
+ win_id: int,
+ parent: QWidget = None) -> None:
+ super().__init__(splitter, win_id, parent)
+ self._check_devtools_resources()
+ view = WebEngineInspectorView()
+ self._settings = webenginesettings.WebEngineSettings(view.settings())
+ self._set_widget(view)
+
+ def _check_devtools_resources(self) -> None:
+ """Make sure that the devtools resources are available on Fedora.
+
+ Fedora packages devtools resources into its own package. If it's not
+ installed, we show a nice error instead of a blank inspector.
+ """
+ dist = version.distribution()
+ if dist is None or dist.parsed != version.Distribution.fedora:
+ return
+
+ data_path = pathlib.Path(QLibraryInfo.location(QLibraryInfo.DataPath))
+ pak = data_path / 'resources' / 'qtwebengine_devtools_resources.pak'
+ if not pak.exists():
+ raise inspector.Error("QtWebEngine devtools resources not found, "
+ "please install the qt5-webengine-devtools "
+ "Fedora package.")
+
+ def inspect(self, page: QWebEnginePage) -> None: # type: ignore[override]
inspector_page = self._widget.page()
inspector_page.setInspectedPage(page)
self._settings.update_for_url(inspector_page.requestedUrl())
- def inspect(self, page):
- try:
- self._inspect_new(page)
- except AttributeError:
- self._inspect_old(page)
+ def _needs_recreate(self) -> bool:
+ """Recreate the inspector when detaching to a window.
+
+ WORKAROUND for what's likely an unknown Qt bug.
+ """
+ return qtutils.version_check('5.12')
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index 69d6daeb4..c161afd0c 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -1359,8 +1359,12 @@ class WebEngineTab(browsertab.AbstractTab):
if fp is not None:
fp.installEventFilter(self._tab_event_filter)
self._child_event_filter = eventfilter.ChildEventFilter(
- eventfilter=self._tab_event_filter, widget=self._widget,
- win_id=self.win_id, parent=self)
+ eventfilter=self._tab_event_filter,
+ widget=self._widget,
+ win_id=self.win_id,
+ focus_workaround=qtutils.version_check(
+ '5.11', compiled=False, exact=True),
+ parent=self)
self._widget.installEventFilter(self._child_event_filter)
@pyqtSlot()
diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py
index 9f2984f8d..7d194fe8a 100644
--- a/qutebrowser/browser/webengine/webview.py
+++ b/qutebrowser/browser/webengine/webview.py
@@ -54,10 +54,10 @@ class WebEngineView(QWebEngineView):
parent=self)
self.setPage(page)
- if qtutils.version_check('5.11', compiled=False):
+ if qtutils.version_check('5.11.0', compiled=False, exact=True):
# Set a PseudoLayout as a WORKAROUND for
# https://bugreports.qt.io/browse/QTBUG-68224
- # and other related issues.
+ # and other related issues. (Fixed in Qt 5.11.1)
sip.delete(self.layout())
self._layout = miscwidgets.PseudoLayout(self)
diff --git a/qutebrowser/browser/webkit/webkitinspector.py b/qutebrowser/browser/webkit/webkitinspector.py
index b08bbea22..603a0a2bb 100644
--- a/qutebrowser/browser/webkit/webkitinspector.py
+++ b/qutebrowser/browser/webkit/webkitinspector.py
@@ -20,21 +20,25 @@
"""Customized QWebInspector for QtWebKit."""
from PyQt5.QtWebKit import QWebSettings
-from PyQt5.QtWebKitWidgets import QWebInspector
+from PyQt5.QtWebKitWidgets import QWebInspector, QWebPage
+from PyQt5.QtWidgets import QWidget
from qutebrowser.browser import inspector
+from qutebrowser.misc import miscwidgets
class WebKitInspector(inspector.AbstractWebInspector):
"""A web inspector for QtWebKit."""
- def __init__(self, parent=None):
- super().__init__(parent)
+ def __init__(self, splitter: miscwidgets.InspectorSplitter,
+ win_id: int,
+ parent: QWidget = None) -> None:
+ super().__init__(splitter, win_id, parent)
qwebinspector = QWebInspector()
self._set_widget(qwebinspector)
- def inspect(self, page):
+ def inspect(self, page: QWebPage) -> None: # type: ignore[override]
settings = QWebSettings.globalSettings()
settings.setAttribute(QWebSettings.DeveloperExtrasEnabled, True)
self._widget.setPage(page)
diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py
index 26fbcdf4f..b4f565d77 100644
--- a/qutebrowser/completion/completionwidget.py
+++ b/qutebrowser/completion/completionwidget.py
@@ -162,13 +162,13 @@ class CompletionView(QTreeView):
pixel_widths = [(width * perc // 100) for perc in column_widths]
delta = self.verticalScrollBar().sizeHint().width()
- if pixel_widths[-1] > delta:
- pixel_widths[-1] -= delta
- else:
- pixel_widths[-2] -= delta
+ for i, width in reversed(list(enumerate(pixel_widths))):
+ if width > delta:
+ pixel_widths[i] -= delta
+ break
for i, w in enumerate(pixel_widths):
- assert w >= 0, i
+ assert w >= 0, (i, w)
self.setColumnWidth(i, w)
def _next_idx(self, upwards):
diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py
index 5ce9c56d2..d2955f48c 100644
--- a/qutebrowser/completion/models/miscmodels.py
+++ b/qutebrowser/completion/models/miscmodels.py
@@ -22,8 +22,9 @@
import typing
from qutebrowser.config import config, configdata
-from qutebrowser.utils import objreg, log
+from qutebrowser.utils import objreg, log, utils
from qutebrowser.completion.models import completionmodel, listcategory, util
+from qutebrowser.browser import inspector
def command(*, info):
@@ -49,7 +50,7 @@ def helptopic(*, info):
return model
-def quickmark(*, info=None): # pylint: disable=unused-argument
+def quickmark(*, info=None):
"""A CompletionModel filled with all quickmarks."""
def delete(data: typing.Sequence[str]) -> None:
"""Delete a quickmark from the completion menu."""
@@ -58,6 +59,7 @@ def quickmark(*, info=None): # pylint: disable=unused-argument
log.completion.debug('Deleting quickmark {}'.format(name))
quickmark_manager.delete(name)
+ utils.unused(info)
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
marks = objreg.get('quickmark-manager').marks.items()
model.add_category(listcategory.ListCategory('Quickmarks', marks,
@@ -66,7 +68,7 @@ def quickmark(*, info=None): # pylint: disable=unused-argument
return model
-def bookmark(*, info=None): # pylint: disable=unused-argument
+def bookmark(*, info=None):
"""A CompletionModel filled with all bookmarks."""
def delete(data: typing.Sequence[str]) -> None:
"""Delete a bookmark from the completion menu."""
@@ -75,6 +77,7 @@ def bookmark(*, info=None): # pylint: disable=unused-argument
bookmark_manager = objreg.get('bookmark-manager')
bookmark_manager.delete(urlstr)
+ utils.unused(info)
model = completionmodel.CompletionModel(column_widths=(30, 70, 0))
marks = objreg.get('bookmark-manager').marks.items()
model.add_category(listcategory.ListCategory('Bookmarks', marks,
@@ -83,9 +86,10 @@ def bookmark(*, info=None): # pylint: disable=unused-argument
return model
-def session(*, info=None): # pylint: disable=unused-argument
+def session(*, info=None):
"""A CompletionModel filled with session names."""
from qutebrowser.misc import sessions
+ utils.unused(info)
model = completionmodel.CompletionModel()
try:
sess = ((name,) for name
@@ -151,11 +155,12 @@ def _buffer(*, win_id_filter=lambda _win_id: True, add_win_id=True):
return model
-def buffer(*, info=None): # pylint: disable=unused-argument
+def buffer(*, info=None):
"""A model to complete on open tabs across all windows.
Used for switching the buffer command.
"""
+ utils.unused(info)
return _buffer()
@@ -201,3 +206,13 @@ def window(*, info):
model.add_category(listcategory.ListCategory("Windows", windows))
return model
+
+
+def inspector_position(*, info):
+ """A model for possible inspector positions."""
+ utils.unused(info)
+ model = completionmodel.CompletionModel(column_widths=(100, 0, 0))
+ positions = [(e.name,) for e in inspector.Position]
+ category = listcategory.ListCategory("Position (optional)", positions)
+ model.add_category(category)
+ return model
diff --git a/qutebrowser/components/misccommands.py b/qutebrowser/components/misccommands.py
index b8c4b98b4..3b38b53e5 100644
--- a/qutebrowser/components/misccommands.py
+++ b/qutebrowser/components/misccommands.py
@@ -35,6 +35,9 @@ from PyQt5.QtPrintSupport import QPrintPreviewDialog
from qutebrowser.api import cmdutils, apitypes, message, config
+# FIXME should be part of qutebrowser.api?
+from qutebrowser.completion.models import miscmodels
+
@cmdutils.register(name='reload')
@cmdutils.argument('tab', value=cmdutils.Value.count_tab)
@@ -313,3 +316,27 @@ def debug_trace(expr: str = "") -> None:
eval('hunter.trace({})'.format(expr))
except Exception as e:
raise cmdutils.CommandError("{}: {}".format(e.__class__.__name__, e))
+
+
+@cmdutils.register()
+@cmdutils.argument('tab', value=cmdutils.Value.cur_tab)
+@cmdutils.argument('position', completion=miscmodels.inspector_position)
+def devtools(tab: apitypes.Tab,
+ position: apitypes.InspectorPosition = None) -> None:
+ """Toggle the developer tools (web inspector).
+
+ Args:
+ position: Where to open the devtools
+ (right/left/top/bottom/window).
+ """
+ try:
+ tab.private_api.toggle_inspector(position)
+ except apitypes.InspectorError as e:
+ raise cmdutils.CommandError(e)
+
+
+@cmdutils.register(deprecated='Use :devtools instead')
+@cmdutils.argument('tab', value=cmdutils.Value.cur_tab)
+def inspector(tab: apitypes.Tab) -> None:
+ """Toggle the web inspector."""
+ devtools(tab)
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 146e98f97..2b5b94042 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -884,7 +884,7 @@ content.xss_auditing:
desc: >-
Monitor load requests for cross-site scripting attempts.
- Suspicious scripts will be blocked and reported in the inspector's
+ Suspicious scripts will be blocked and reported in the devtools
JavaScript console.
Note that bypasses for the XSS auditor are widely known and it can be
@@ -3130,7 +3130,12 @@ bindings.default:
gU: navigate up -t
<Ctrl-A>: navigate increment
<Ctrl-X>: navigate decrement
- wi: inspector
+ wi: devtools
+ wIh: devtools left
+ wIj: devtools bottom
+ wIk: devtools top
+ wIl: devtools right
+ wIw: devtools window
gd: download
ad: download-cancel
cd: download-clear
diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py
index a2c4db3f2..f251ca3bd 100644
--- a/qutebrowser/config/configfiles.py
+++ b/qutebrowser/config/configfiles.py
@@ -68,15 +68,19 @@ class StateConfig(configparser.ConfigParser):
else:
self.qt_version_changed = False
- for sect in ['general', 'geometry']:
+ for sect in ['general', 'geometry', 'inspector']:
try:
self.add_section(sect)
except configparser.DuplicateSectionError:
pass
- deleted_keys = ['fooled', 'backend-warning-shown']
- for key in deleted_keys:
- self['general'].pop(key, None)
+ deleted_keys = [
+ ('general', 'fooled'),
+ ('general', 'backend-warning-shown'),
+ ('geometry', 'inspector'),
+ ]
+ for sect, key in deleted_keys:
+ self[sect].pop(key, None)
self['general']['qt_version'] = qt_version
self['general']['version'] = qutebrowser.__version__
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index f9112c6ab..7323bfff1 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -431,19 +431,16 @@ class TabbedBrowser(QWidget):
elif last_close == 'default-page':
self.load_url(config.val.url.default_page, newtab=True)
- def _remove_tab(self, tab, *, add_undo=True, new_undo=True, crashed=False):
+ def _remove_tab(self, tab, *, add_undo=True, new_undo=True):
"""Remove a tab from the tab list and delete it properly.
Args:
tab: The QWebView to be closed.
add_undo: Whether the tab close can be undone.
new_undo: Whether the undo entry should be a new item in the stack.
- crashed: Whether we're closing a tab with crashed renderer process.
"""
idx = self.widget.indexOf(tab)
if idx == -1:
- if crashed:
- return
raise TabDeletedError("tab {} is not contained in "
"TabbedWidget!".format(tab))
if tab is self._now_focused:
@@ -477,11 +474,7 @@ class TabbedBrowser(QWidget):
tab.private_api.shutdown()
self.widget.removeTab(idx)
- if not crashed:
- # WORKAROUND for a segfault when we delete the crashed tab.
- # see https://bugreports.qt.io/browse/QTBUG-58698
- tab.layout().unwrap()
- tab.deleteLater()
+ tab.deleteLater()
def undo(self):
"""Undo removing of a tab or tabs."""
@@ -935,18 +928,11 @@ class TabbedBrowser(QWidget):
tab.set_html(html)
log.webview.error(msg)
- if qtutils.version_check('5.9', compiled=False):
- url_string = tab.url(requested=True).toDisplayString()
- error_page = jinja.render(
- 'error.html', title="Error loading {}".format(url_string),
- url=url_string, error=msg)
- QTimer.singleShot(100, lambda: show_error_page(error_page))
- else:
- # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698
- message.error(msg)
- self._remove_tab(tab, crashed=True)
- if self.widget.count() == 0:
- self.tabopen(QUrl('about:blank'))
+ url_string = tab.url(requested=True).toDisplayString()
+ error_page = jinja.render(
+ 'error.html', title="Error loading {}".format(url_string),
+ url=url_string, error=msg)
+ QTimer.singleShot(100, lambda: show_error_page(error_page))
def resizeEvent(self, e):
"""Extend resizeEvent of QWidget to emit a resized signal afterwards.
diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py
index 2d72af780..868796e99 100644
--- a/qutebrowser/misc/miscwidgets.py
+++ b/qutebrowser/misc/miscwidgets.py
@@ -23,12 +23,14 @@ import typing
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QSize, QTimer
from PyQt5.QtWidgets import (QLineEdit, QWidget, QHBoxLayout, QLabel,
- QStyleOption, QStyle, QLayout, QApplication)
-from PyQt5.QtGui import QValidator, QPainter
+ QStyleOption, QStyle, QLayout, QApplication,
+ QSplitter)
+from PyQt5.QtGui import QValidator, QPainter, QResizeEvent
-from qutebrowser.config import config
-from qutebrowser.utils import utils
+from qutebrowser.config import config, configfiles
+from qutebrowser.utils import utils, log
from qutebrowser.misc import cmdhistory
+from qutebrowser.browser import inspector
from qutebrowser.keyinput import keyutils
@@ -237,12 +239,15 @@ class WrapperLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
- self._widget = typing.cast(QWidget, None)
+ self._widget = None # type: typing.Optional[QWidget]
def addItem(self, _widget):
raise utils.Unreachable
def sizeHint(self):
+ """Get the size of the underlying widget."""
+ if self._widget is None:
+ return QSize()
return self._widget.sizeHint()
def itemAt(self, _index):
@@ -252,6 +257,9 @@ class WrapperLayout(QLayout):
raise utils.Unreachable
def setGeometry(self, rect):
+ """Pass through setGeometry calls to the underlying widget."""
+ if self._widget is None:
+ return
self._widget.setGeometry(rect)
def wrap(self, container, widget):
@@ -260,10 +268,6 @@ class WrapperLayout(QLayout):
container.setFocusProxy(widget)
widget.setParent(container)
- def unwrap(self):
- self._widget.setParent(None) # type: ignore[call-overload]
- self._widget.deleteLater()
-
class PseudoLayout(QLayout):
@@ -345,6 +349,143 @@ class FullscreenNotification(QLabel):
self.deleteLater()
+class InspectorSplitter(QSplitter):
+
+ """Allows putting an inspector inside the tab.
+
+ Attributes:
+ _main_idx: index of the main webview widget
+ _position: position of the inspector (right/left/top/bottom)
+ _preferred_size: the preferred size of the inpector widget in pixels
+
+ Class attributes:
+ _PROTECTED_MAIN_SIZE: How much space should be reserved for the main
+ content (website).
+ _SMALL_SIZE_THRESHOLD: If the window size is under this threshold, we
+ consider this a temporary "emergency" situation.
+ """
+
+ _PROTECTED_MAIN_SIZE = 150
+ _SMALL_SIZE_THRESHOLD = 300
+
+ def __init__(self, main_webview: QWidget, parent: QWidget = None) -> None:
+ super().__init__(parent)
+ self.addWidget(main_webview)
+ self.setFocusProxy(main_webview)
+ self.splitterMoved.connect(self._on_splitter_moved)
+ self._main_idx = None # type: typing.Optional[int]
+ self._inspector_idx = None # type: typing.Optional[int]
+ self._position = None # type: typing.Optional[inspector.Position]
+ self._preferred_size = None # type: typing.Optional[int]
+
+ def set_inspector(self, inspector_widget: inspector.AbstractWebInspector,
+ position: inspector.Position) -> None:
+ """Set the position of the inspector."""
+ assert position != inspector.Position.window
+
+ if position in [inspector.Position.right, inspector.Position.bottom]:
+ self._main_idx = 0
+ self._inspector_idx = 1
+ else:
+ self._inspector_idx = 0
+ self._main_idx = 1
+
+ self.setOrientation(Qt.Horizontal
+ if position in [inspector.Position.left,
+ inspector.Position.right]
+ else Qt.Vertical)
+ self.insertWidget(self._inspector_idx, inspector_widget)
+ self._position = position
+ self._load_preferred_size()
+ self._adjust_size()
+
+ def _save_preferred_size(self) -> None:
+ """Save the preferred size of the inspector widget."""
+ assert self._position is not None
+ size = str(self._preferred_size)
+ configfiles.state['inspector'][self._position.name] = size
+
+ def _load_preferred_size(self) -> None:
+ """Load the preferred size of the inspector widget."""
+ assert self._position is not None
+ full = (self.width() if self.orientation() == Qt.Horizontal
+ else self.height())
+
+ # If we first open the inspector with a window size of < 300px
+ # (self._SMALL_SIZE_THRESHOLD), we don't want to default to half of the
+ # window size as the small window is likely a temporary situation and
+ # the inspector isn't very usable in that state.
+ self._preferred_size = max(self._SMALL_SIZE_THRESHOLD, full // 2)
+
+ try:
+ size = int(configfiles.state['inspector'][self._position.name])
+ except KeyError:
+ # First start
+ pass
+ except ValueError as e:
+ log.misc.error("Could not read inspector size: {}".format(e))
+ else:
+ self._preferred_size = int(size)
+
+ def _adjust_size(self) -> None:
+ """Adjust the size of the inspector similarly to Chromium.
+
+ In general, we want to keep the absolute size of the inspector (rather
+ than the ratio) the same, as it's confusing when the layout of its
+ contents changes.
+
+ We're essentially handling three different cases:
+
+ 1) We have plenty of space -> Keep inspector at the preferred absolute
+ size.
+
+ 2) We're slowly running out of space. Make sure the page still has
+ 150px (self._PROTECTED_MAIN_SIZE) left, give the rest to the
+ inspector.
+
+ 3) The window is very small (< 300px, self._SMALL_SIZE_THRESHOLD).
+ Keep Qt's behavior of keeping the aspect ratio, as all hope is lost
+ at this point.
+ """
+ sizes = self.sizes()
+ total = sizes[0] + sizes[1]
+
+ assert self._main_idx is not None
+ assert self._inspector_idx is not None
+ assert self._preferred_size is not None
+
+ if total >= self._preferred_size + self._PROTECTED_MAIN_SIZE:
+ # Case 1 above
+ sizes[self._inspector_idx] = self._preferred_size
+ sizes[self._main_idx] = total - self._preferred_size
+ self.setSizes(sizes)
+ elif (sizes[self._main_idx] < self._PROTECTED_MAIN_SIZE and
+ total >= self._SMALL_SIZE_THRESHOLD):
+ # Case 2 above
+ handle_size = self.handleWidth()
+ sizes[self._main_idx] = (
+ self._PROTECTED_MAIN_SIZE - handle_size // 2)
+ sizes[self._inspector_idx] = (
+ total - self._PROTECTED_MAIN_SIZE + handle_size // 2)
+ self.setSizes(sizes)
+ else:
+ # Case 3 above
+ pass
+
+ @pyqtSlot()
+ def _on_splitter_moved(self) -> None:
+ assert self._inspector_idx is not None
+ sizes = self.sizes()
+ self._preferred_size = sizes[self._inspector_idx]
+ self._save_preferred_size()
+
+ def resizeEvent(self, e: QResizeEvent) -> None:
+ """Window resize event."""
+ super().resizeEvent(e)
+ if self.count() == 2:
+ self._adjust_size()
+
+
class KeyTesterWidget(QWidget):
"""Widget displaying key presses."""
diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py
index 8765f5217..bbe56c6c7 100644
--- a/qutebrowser/qutebrowser.py
+++ b/qutebrowser/qutebrowser.py
@@ -83,13 +83,13 @@ def get_argparser():
parser.add_argument('--backend', choices=['webkit', 'webengine'],
help="Which backend to use.")
parser.add_argument('--enable-webengine-inspector', action='store_true',
- help="Enable the web inspector for QtWebEngine. Note "
- "that this is a SECURITY RISK and you should not "
- "visit untrusted websites with the inspector turned "
- "on. See https://bugreports.qt.io/browse/QTBUG-50725 "
- "for more details. This is not needed anymore since "
- "Qt 5.11 where the inspector is always enabled and "
- "secure.")
+ help="Enable the web inspector / devtools for "
+ "QtWebEngine. Note that this is a SECURITY RISK and "
+ "you should not visit untrusted websites with the "
+ "inspector turned on. See "
+ "https://bugreports.qt.io/browse/QTBUG-50725 for more "
+ "details. This is not needed anymore since Qt 5.11 "
+ "where the inspector is always enabled and secure.")
parser.add_argument('--json-args', help=argparse.SUPPRESS)
parser.add_argument('--temp-basedir-restarted', help=argparse.SUPPRESS)
diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py
index 60a4f02ba..0b0347ca2 100644
--- a/tests/helpers/fixtures.py
+++ b/tests/helpers/fixtures.py
@@ -676,3 +676,26 @@ def web_history(fake_save_manager, tmpdir, init_sql, config_stub, stubs,
web_history = history.WebHistory(stubs.FakeHistoryProgress())
monkeypatch.setattr(history, 'web_history', web_history)
return web_history
+
+
+@pytest.fixture
+def blue_widget(qtbot):
+ widget = QWidget()
+ widget.setStyleSheet('background-color: blue;')
+ qtbot.add_widget(widget)
+ return widget
+
+
+@pytest.fixture
+def red_widget(qtbot):
+ widget = QWidget()
+ widget.setStyleSheet('background-color: red;')
+ qtbot.add_widget(widget)
+ return widget
+
+
+@pytest.fixture
+def state_config(data_tmpdir, monkeypatch):
+ state = configfiles.StateConfig()
+ monkeypatch.setattr(configfiles, 'state', state)
+ return state
diff --git a/tests/unit/browser/test_inspector.py b/tests/unit/browser/test_inspector.py
new file mode 100644
index 000000000..c7e255973
--- /dev/null
+++ b/tests/unit/browser/test_inspector.py
@@ -0,0 +1,153 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+import pytest
+
+from PyQt5.QtWidgets import QWidget
+
+from qutebrowser.browser import inspector
+from qutebrowser.misc import miscwidgets
+
+
+class FakeInspector(inspector.AbstractWebInspector):
+
+ def __init__(self,
+ inspector_widget: QWidget,
+ splitter: miscwidgets.InspectorSplitter,
+ win_id: int,
+ parent: QWidget = None) -> None:
+ super().__init__(splitter, win_id, parent)
+ self._set_widget(inspector_widget)
+ self._inspected_page = None
+ self.needs_recreate = False
+
+ def inspect(self, page):
+ self._inspected_page = page
+
+ def _needs_recreate(self):
+ return self.needs_recreate
+
+
+@pytest.fixture
+def webview_widget(blue_widget):
+ return blue_widget
+
+
+@pytest.fixture
+def inspector_widget(red_widget):
+ return red_widget
+
+
+@pytest.fixture
+def splitter(qtbot, webview_widget):
+ splitter = miscwidgets.InspectorSplitter(webview_widget)
+ qtbot.add_widget(splitter)
+ return splitter
+
+
+@pytest.fixture
+def fake_inspector(qtbot, splitter, inspector_widget,
+ state_config, mode_manager):
+ insp = FakeInspector(inspector_widget=inspector_widget,
+ splitter=splitter,
+ win_id=0)
+ qtbot.add_widget(insp)
+ return insp
+
+
+@pytest.mark.parametrize('position, splitter_count, window_visible', [
+ (inspector.Position.window, 1, True),
+ (inspector.Position.left, 2, False),
+ (inspector.Position.top, 2, False),
+])
+def test_set_position(position, splitter_count, window_visible,
+ fake_inspector, splitter):
+ fake_inspector.set_position(position)
+ assert splitter.count() == splitter_count
+ assert (fake_inspector.isWindow() and
+ fake_inspector.isVisible()) == window_visible
+
+
+def test_toggle_window(fake_inspector):
+ fake_inspector.set_position(inspector.Position.window)
+ for visible in [True, False, True]:
+ assert (fake_inspector.isWindow() and
+ fake_inspector.isVisible()) == visible
+ fake_inspector.toggle()
+
+
+def test_toggle_docked(fake_inspector, splitter, inspector_widget):
+ fake_inspector.set_position(inspector.Position.right)
+ splitter.show()
+ for visible in [True, False, True]:
+ assert inspector_widget.isVisible() == visible
+ fake_inspector.toggle()
+
+
+def test_implicit_toggling(fake_inspector, splitter, inspector_widget):
+ fake_inspector.set_position(inspector.Position.right)
+ splitter.show()
+ assert inspector_widget.isVisible()
+ fake_inspector.set_position(None)
+ assert not inspector_widget.isVisible()
+
+
+def test_position_saving(fake_inspector, state_config):
+ assert 'position' not in state_config['inspector']
+ fake_inspector.set_position(inspector.Position.left)
+ assert state_config['inspector']['position'] == 'left'
+
+
+@pytest.mark.parametrize('config_value, expected', [
+ (None, inspector.Position.right),
+ ('top', inspector.Position.top),
+])
+def test_position_loading(config_value, expected,
+ fake_inspector, state_config):
+ if config_value is None:
+ assert 'position' not in state_config['inspector']
+ else:
+ state_config['inspector']['position'] = config_value
+
+ fake_inspector.set_position(None)
+ assert fake_inspector._position == expected
+
+
+@pytest.mark.parametrize('hidden_again', [True, False])
+@pytest.mark.parametrize('needs_recreate', [True, False])
+def test_detach_after_toggling(hidden_again, needs_recreate,
+ fake_inspector, inspector_widget, splitter,
+ qtbot):
+ """Make sure we can still detach into a window after showing inline."""
+ fake_inspector.set_position(inspector.Position.right)
+ splitter.show()
+ assert inspector_widget.isVisible()
+
+ if hidden_again:
+ fake_inspector.toggle()
+ assert not inspector_widget.isVisible()
+
+ if needs_recreate:
+ fake_inspector.needs_recreate = True
+ with qtbot.waitSignal(fake_inspector.recreate):
+ fake_inspector.set_position(inspector.Position.window)
+ else:
+ with qtbot.assertNotEmitted(fake_inspector.recreate):
+ fake_inspector.set_position(inspector.Position.window)
+ assert fake_inspector.isVisible() and fake_inspector.isWindow()
diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py
index 0a3668d39..3436d2cf4 100644
--- a/tests/unit/config/test_configfiles.py
+++ b/tests/unit/config/test_configfiles.py
@@ -83,6 +83,8 @@ def autoconfig(config_tmpdir):
'version = 1.2.3\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
('[general]\n'
'fooled = true',
@@ -92,6 +94,8 @@ def autoconfig(config_tmpdir):
'version = 1.2.3\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
('[general]\n'
'foobar = 42',
@@ -102,6 +106,8 @@ def autoconfig(config_tmpdir):
'version = 1.2.3\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
(None,
True,
@@ -111,6 +117,8 @@ def autoconfig(config_tmpdir):
'newval = 23\n'
'\n'
'[geometry]\n'
+ '\n'
+ '[inspector]\n'
'\n'),
])
def test_state_config(fake_save_manager, data_tmpdir, monkeypatch,
diff --git a/tests/unit/misc/test_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py
index daa556833..0b05d8657 100644
--- a/tests/unit/misc/test_miscwidgets.py
+++ b/tests/unit/misc/test_miscwidgets.py
@@ -17,14 +17,16 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-"""Test widgets in miscwidgets module."""
-
+import math
+import logging
from unittest import mock
+
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtWidgets import QApplication, QWidget
import pytest
from qutebrowser.misc import miscwidgets
+from qutebrowser.browser import inspector
class TestCommandLineEdit:
@@ -142,3 +144,148 @@ class TestFullscreenNotification:
qtbot.add_widget(w)
with qtbot.waitSignal(w.destroyed):
w.set_timeout(1)
+
+
+@pytest.mark.usefixtures('state_config')
+class TestInspectorSplitter:
+
+ @pytest.fixture
+ def fake_webview(self, blue_widget):
+ return blue_widget
+
+ @pytest.fixture
+ def fake_inspector(self, red_widget):
+ return red_widget
+
+ @pytest.fixture
+ def splitter(self, qtbot, fake_webview):
+ inspector_splitter = miscwidgets.InspectorSplitter(fake_webview)
+ qtbot.add_widget(inspector_splitter)
+ return inspector_splitter
+
+ def test_no_inspector(self, splitter, fake_webview):
+ assert splitter.count() == 1
+ assert splitter.widget(0) is fake_webview
+ assert splitter.focusProxy() is fake_webview
+
+ def test_no_inspector_resize(self, splitter):
+ splitter.show()
+ splitter.resize(800, 600)
+
+ @pytest.mark.parametrize(
+ 'position, orientation, inspector_idx, webview_idx', [
+ (inspector.Position.left, Qt.Horizontal, 0, 1),
+ (inspector.Position.right, Qt.Horizontal, 1, 0),
+ (inspector.Position.top, Qt.Vertical, 0, 1),
+ (inspector.Position.bottom, Qt.Vertical, 1, 0),
+ ]
+ )
+ def test_set_inspector(self, position, orientation,
+ inspector_idx, webview_idx,
+ splitter, fake_inspector, fake_webview):
+ splitter.set_inspector(fake_inspector, position)
+
+ assert splitter.indexOf(fake_inspector) == inspector_idx
+ assert splitter._inspector_idx == inspector_idx
+
+ assert splitter.indexOf(fake_webview) == webview_idx
+ assert splitter._main_idx == webview_idx
+
+ assert splitter.orientation() == orientation
+
+ @pytest.mark.parametrize(
+ 'config, width, height, position, expected_size', [
+ # No config but enough big window
+ (None, 1024, 768, inspector.Position.left, 512),
+ (None, 1024, 768, inspector.Position.top, 384),
+ # No config and small window
+ (None, 320, 240, inspector.Position.left, 300),
+ (None, 320, 240, inspector.Position.top, 300),
+ # Invalid config
+ ('verybig', 1024, 768, inspector.Position.left, 512),
+ # Value from config
+ ('666', 1024, 768, inspector.Position.left, 666),
+ ]
+ )
+ def test_read_size(self, config, width, height, position, expected_size,
+ state_config, splitter, fake_inspector, caplog):
+ if config is not None:
+ state_config['inspector'] = {position.name: config}
+
+ splitter.resize(width, height)
+ assert splitter.size() == QSize(width, height)
+
+ with caplog.at_level(logging.ERROR):
+ splitter.set_inspector(fake_inspector, position)
+
+ assert splitter._preferred_size == expected_size
+
+ if config == {'left': 'verybig'}:
+ assert caplog.messages == ["Could not read inspector size: "
+ "invalid literal for int() with "
+ "base 10: 'verybig'"]
+
+ @pytest.mark.parametrize('position', [
+ inspector.Position.left,
+ inspector.Position.right,
+ inspector.Position.top,
+ inspector.Position.bottom,
+ ])
+ def test_save_size(self, position, state_config, splitter, fake_inspector):
+ splitter.set_inspector(fake_inspector, position)
+ splitter._preferred_size = 1337
+ splitter._save_preferred_size()
+ assert state_config['inspector'][position.name] == '1337'
+
+ @pytest.mark.parametrize(
+ 'old_window_size, preferred_size, new_window_size, '
+ 'exp_inspector_size', [
+ # Plenty of space -> Keep inspector at configured absolute size
+ (600, 300, # 1/2 of window
+ 500, 300), # 300px of 600px -> 300px of 500px
+
+ # Slowly running out of space -> Reserve space for website
+ (600, 450, # 3/4 of window
+ 500, 350), # 450px of 600px -> 350px of 500px
+ # (so website has 150px)
+
+ # Very small window -> Keep ratio distribution
+ (600, 300, # 1/2 of window
+ 200, 100), # 300px of 600px -> 100px of 200px (1/2)
+ ]
+ )
+ @pytest.mark.parametrize('position', [
+ inspector.Position.left, inspector.Position.right,
+ inspector.Position.top, inspector.Position.bottom])
+ def test_adjust_size(self, old_window_size, preferred_size,
+ new_window_size, exp_inspector_size,
+ position, splitter, fake_inspector, qtbot):
+ def resize(dim):
+ size = (QSize(dim, 666) if splitter.orientation() == Qt.Horizontal
+ else QSize(666, dim))
+ splitter.resize(size)
+ if splitter.size() != size:
+ pytest.skip("Resizing window failed")
+
+ splitter.set_inspector(fake_inspector, position)
+ splitter.show()
+ resize(old_window_size)
+
+ handle_width = splitter.handleWidth()
+ splitter_idx = 1
+ if position in [inspector.Position.left, inspector.Position.top]:
+ splitter_pos = preferred_size - handle_width//2
+ else:
+ splitter_pos = old_window_size - preferred_size - handle_width//2
+ splitter.moveSplitter(splitter_pos, splitter_idx)
+
+ resize(new_window_size)
+
+ sizes = splitter.sizes()
+ inspector_size = sizes[splitter._inspector_idx]
+ main_size = sizes[splitter._main_idx]
+ exp_main_size = new_window_size - exp_inspector_size - handle_width//2
+ exp_inspector_size -= math.ceil(handle_width/2)
+
+ assert (inspector_size, main_size) == (exp_inspector_size,
+ exp_main_size)
diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py
index e052751b5..a6e86efa9 100644
--- a/tests/unit/misc/test_sessions.py
+++ b/tests/unit/misc/test_sessions.py
@@ -182,12 +182,6 @@ def test_get_session_name(config_stub, sess_man, arg, config, current,
class TestSave:
@pytest.fixture
- def state_config(self, monkeypatch):
- state = {'general': {}}
- monkeypatch.setattr(sessions.configfiles, 'state', state)
- return state
-
- @pytest.fixture
def fake_history(self, stubs, tabbed_browser_stubs, monkeypatch, webview):
"""Fixture which provides a window with a fake history."""
win = FakeMainWindow(b'fake-geometry-0', win_id=0)