diff options
author | Florian Bruhin <me@the-compiler.org> | 2020-06-23 12:43:34 +0200 |
---|---|---|
committer | Florian Bruhin <me@the-compiler.org> | 2020-06-23 12:43:34 +0200 |
commit | 3a5afadf5a80e16597c0601667ee90966bba277f (patch) | |
tree | 0c69e1fc066a59abcfb1795c36abfcbe4e1acdbb | |
parent | ac1539f3527de8400df2ac6377c38514450d31b0 (diff) | |
parent | 370bd12a1512f5164be0e3ae1838515957e587fb (diff) | |
download | qutebrowser-3a5afadf5a80e16597c0601667ee90966bba277f.tar.gz qutebrowser-3a5afadf5a80e16597c0601667ee90966bba277f.zip |
Merge branch 'docked-inspector'
26 files changed, 919 insertions, 180 deletions
@@ -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) |