diff --git a/.flake8 b/.flake8 index 477271f29..0265c969b 100644 --- a/.flake8 +++ b/.flake8 @@ -23,18 +23,12 @@ exclude = .*,__pycache__,resources.py # D402: First line should not be function's signature (false-positives) # D403: First word of the first line should be properly capitalized # (false-positives) -# H101: Use TODO(NAME) -# H201: bare except -# H238: Use new-stule classes -# H301: one import per line -# H306: imports not in alphabetical order ignore = E128,E226,E265,E501,E402,E266,E731, F401, N802, P101,P102,P103, - D102,D103,D104,D105,D209,D211,D402,D403, - H101,H201,H238,H301,H306 + D102,D103,D104,D105,D209,D211,D402,D403 min-version = 3.4.0 max-complexity = 12 putty-auto-ignore = True diff --git a/.gitignore b/.gitignore index 71822e89e..fd694e9e4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,12 @@ __pycache__ /prof /venv TODO -/scripts/testbrowser_cpp/Makefile -/scripts/testbrowser_cpp/main.o -/scripts/testbrowser_cpp/testbrowser +/scripts/testbrowser_cpp/webkit/Makefile +/scripts/testbrowser_cpp/webkit/main.o +/scripts/testbrowser_cpp/webkit/testbrowser +/scripts/testbrowser_cpp/webkit/.qmake.stash +/scripts/testbrowser_cpp/webengine/Makefile +/scripts/testbrowser_cpp/webengine/main.o +/scripts/testbrowser_cpp/webengine/testbrowser +/scripts/testbrowser_cpp/webengine/.qmake.stash /scripts/dev/pylint_checkers/qute_pylint.egg-info diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 09dcca173..8a796a2b2 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -139,6 +139,7 @@ Changed - The `qute:settings` page now also shows option descriptions. - `qute:version` and `qutebrowser --version` now show various important paths - `:spawn`/userscripts now show a nicer error when a script wasn't found +- Various functionality now works when javascript is disabled with QtWebKit Deprecated ~~~~~~~~~~ @@ -168,23 +169,15 @@ Removed Fixed ~~~~~ -- `:bind` can now be used to bind to an alias (binding by editing `keys.conf` - already worked before) -- The command completion now updates correctly when changing aliases - `:undo` now doesn't undo tabs "closed" by `:tab-detach` anymore. - Fixed an issue with hint chars not being cleared correctly when leaving hint mode. - `:tab-detach` now fails correctly when there's only one tab open. - Various small issues with the command completion -- The tabbar now displays correctly with the Adwaita Qt theme -- The default `sk` keybinding now sets the commandline to `:bind` correctly - Fixed hang when using multiple spaces in a row with the URL completion -- Fixed crash when closing a window without focusing it -- Userscripts now can access QUTE_FIFO correctly on Windows -- Compatibility with pdfjs v1.6.210 -v0.8.3 (unreleased) -------------------- +v0.8.3 +------ Fixed ~~~~~ @@ -193,6 +186,15 @@ Fixed - Fixed `:open-editor` (``) on Windows - Fixed crash when setting `general -> auto-save-interval` to a too big value. - Fixed crash when using hints on Void Linux. +- Fixed compatibility with Python 3.5.2+ on Debian unstable +- Compatibility with pdfjs v1.6.210 +- `:bind` can now be used to bind to an alias (binding by editing `keys.conf` + already worked before) +- The command completion now updates correctly when changing aliases +- The tabbar now displays correctly with the Adwaita Qt theme +- The default `sk` keybinding now sets the commandline to `:bind` correctly +- Fixed crash when closing a window without focusing it +- Userscripts now can access QUTE_FIFO correctly on Windows v0.8.2 ------ diff --git a/CONTRIBUTING.asciidoc b/CONTRIBUTING.asciidoc index e7ed06481..05ec6df67 100644 --- a/CONTRIBUTING.asciidoc +++ b/CONTRIBUTING.asciidoc @@ -526,6 +526,21 @@ generate code and subsequently overwrite part or all of it. Running with all will slow Valgrind down noticeably. ____ +Setting up a Windows Development Environment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* Install https://www.python.org/downloads/release/python-344/[Python 3.4] +* Install https://sourceforge.net/projects/pyqt/files/PyQt5/PyQt-5.5.1/[PyQt 5.5] +* Create a file at `C:\Windows\system32\python3.bat` with the following content: + `@C:\Python34\python %*` + This will make the Python 3.4 interpreter available as `python3`, which is used by various development scripts. +* Install git from the https://git-scm.com/download/win[git-scm downloads page] + Try not to enable `core.autocrlf`, since that will cause `flake8` to complain a lot. Use an editor that can deal with plain line feeds instead. +* Clone your favourite qutebrowser repository. +* To install tox, open an elevated cmd, enter your working directory and run `pip install -rmisc/requirements/requirements-tox.txt`. + +Note that the `flake8` tox env might not run due to encoding errors despite having LANG/LC_* set correctly. + Style conventions ----------------- diff --git a/INSTALL.asciidoc b/INSTALL.asciidoc index 61ed5fcde..f5ab70daa 100644 --- a/INSTALL.asciidoc +++ b/INSTALL.asciidoc @@ -272,18 +272,12 @@ qutebrowser from source. ==== Homebrew -For Homebrew, a few extra steps are necessary since Homebrew dropped QtWebKit -from Qt 5.6 - however, some users reported this didn't work for them, so using -the `.app` is strongly encouraged. - -This installs a Qt 5.5 and symlinks it so PyQt5 will work with it instead of Qt -5.6. This requires that `qt5` is not installed via Homebrew: +Homebrew's builds of Qt and PyQt no longer include QtWebKit, so it is necessary +to build from source. The build takes several hours on an average laptop. ---- -$ brew install python3 d-bus mysql sip xz -$ brew install homebrew/versions/qt55 -$ brew install --ignore-dependencies pyqt5 -$ ln -s /usr/local/opt/qt55 /usr/local/opt/qt5 +$ brew install qt5 --with-qtwebkit +$ brew install -s pyqt5 $ pip3.5 install qutebrowser ---- diff --git a/README.asciidoc b/README.asciidoc index 86743bc71..b5ea0fb6d 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -16,7 +16,7 @@ image:https://travis-ci.org/The-Compiler/qutebrowser.svg?branch=master["Build St image:https://ci.appveyor.com/api/projects/status/9gmnuip6i1oq7046?svg=true["AppVeyor build status", link="https://ci.appveyor.com/project/The-Compiler/qutebrowser"] image:https://codecov.io/github/The-Compiler/qutebrowser/coverage.svg?branch=master["coverage badge",link="https://codecov.io/github/The-Compiler/qutebrowser?branch=master"] -link:http://www.qutebrowser.org[website] | link:http://blog.qutebrowser.org[blog] | link:https://github.com/The-Compiler/qutebrowser/releases[releases] +link:https://www.qutebrowser.org[website] | link:https://blog.qutebrowser.org[blog] | link:https://github.com/The-Compiler/qutebrowser/releases[releases] // QUTE_WEB_HIDE_END qutebrowser is a keyboard-focused browser with a minimal GUI. It's based @@ -48,8 +48,8 @@ Documentation In addition to the topics mentioned in this README, the following documents are available: -* A http://qutebrowser.org/img/cheatsheet-big.png[key binding cheatsheet]: + -image:http://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="http://qutebrowser.org/img/cheatsheet-big.png"] +* A https://qutebrowser.org/img/cheatsheet-big.png[key binding cheatsheet]: + +image:https://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="https://qutebrowser.org/img/cheatsheet-big.png"] * link:doc/quickstart.asciidoc[Quick start guide] * A https://www.shortcutfoo.com/app/dojos/qutebrowser[free training course] to remember those key bindings. * link:FAQ.asciidoc[Frequently asked questions] @@ -90,7 +90,7 @@ https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[mailinglist] at mailto:qutebrowser@lists.qutebrowser.org[]. For security bugs, please contact me directly at mail@qutebrowser.org, GPG ID -http://www.the-compiler.org/pubkey.asc[0xFD55A072]. +https://www.the-compiler.org/pubkey.asc[0xFD55A072]. Requirements ------------ @@ -155,6 +155,8 @@ Contributors, sorted by the number of commits in descending order: * Alexander Cogneau * Felix Van der Jeugt * Martin Tournoij +* Daniel Karbach +* Kevin Velghe * Raphael Pierzina * Joel Torstensson * Patric Schmitz @@ -162,15 +164,14 @@ Contributors, sorted by the number of commits in descending order: * Claude * Corentin Julé * meles5 -* Kevin Velghe * Philipp Hansch -* Daniel Karbach * Panagiotis Ktistakis * Artur Shaik * Nathan Isom * Thorsten Wißmann * Austin Anderson * Jimmy +* Spreadyy * Niklas Haas * Alexey "Averrin" Nabrodov * nanjekyejoannah @@ -188,6 +189,7 @@ Contributors, sorted by the number of commits in descending order: * error800 * Michael Hoang * Liam BEGUIN +* Julie Engel * skinnay * Zach-Button * Tomasz Kramkowski @@ -211,7 +213,6 @@ Contributors, sorted by the number of commits in descending order: * jnphilipp * Tobias Patzl * Stefan Tatschner -* Spreadyy * Samuel Loury * Peter Michely * Panashe M. Fundira @@ -228,9 +229,11 @@ Contributors, sorted by the number of commits in descending order: * Regina Hug * Mathias Fussenegger * Marcelo Santos +* Joel Bradshaw * Jean-Louis Fuchs * Fritz V155 Reichwald * Franz Fellner +* Eric Drechsel * zwarag * xd1le * rsteube @@ -256,7 +259,6 @@ Contributors, sorted by the number of commits in descending order: * Marcel Schilling * Lazlow Carmichael * Ján Kobezda -* Julie Engel * Johannes Martinsson * Jean-Christophe Petkovich * Jay Kamat diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 7fb91bf6c..b1c10c344 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1000,6 +1000,7 @@ How many steps to zoom out. |<>|Move the cursor or selection to the start of previous block. |<>|Open an external editor with the currently selected form field. |<>|Accept the current prompt. +|<>|Shift the focus of the prompt file completion menu to another item. |<>|Immediately open a download. |<>|Repeat the last executed command. |<>|Move back a character. @@ -1252,6 +1253,15 @@ Accept the current prompt. * +'value'+: If given, uses this value instead of the entered one. For boolean prompts, "yes"/"no" are accepted as value. +[[prompt-item-focus]] +=== prompt-item-focus +Syntax: +:prompt-item-focus 'which'+ + +Shift the focus of the prompt file completion menu to another item. + +==== positional arguments +* +'which'+: 'next', 'prev' + [[prompt-open-download]] === prompt-open-download Syntax: +:prompt-open-download ['cmdline']+ diff --git a/doc/help/index.asciidoc b/doc/help/index.asciidoc index 4396b765f..183435f68 100644 --- a/doc/help/index.asciidoc +++ b/doc/help/index.asciidoc @@ -6,13 +6,13 @@ Documentation The following help pages are currently available: -* link:quickstart.html[Quick start guide] -* link:FAQ.html[Frequently asked questions] -* link:CHANGELOG.html[Change Log] +* link:../quickstart.html[Quick start guide] +* link:../../FAQ.html[Frequently asked questions] +* link:../../CHANGELOG.html[Change Log] * link:commands.html[Documentation of commands] * link:settings.html[Documentation of settings] -* link:userscripts.html[How to write userscripts] -* link:CONTRIBUTING.html[Contributing to qutebrowser] +* link:../userscripts.html[How to write userscripts] +* link:../../CONTRIBUTING.html[Contributing to qutebrowser] Getting help ------------ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index d738a68ca..f2dc26588 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -54,6 +54,7 @@ |<>|Use standard JavaScript modal dialog for alert() and confirm() |<>|Hide the window decoration when using wayland (requires restart) |<>|Keychains that shouldn't be shown in the keyhint dialog +|<>|The rounding radius for the edges of prompts. |============== .Quick reference for section ``network'' @@ -213,8 +214,6 @@ |<>|Color of the scrollbar in completion view |<>|Foreground color of the statusbar. |<>|Background color of the statusbar. -|<>|Foreground color of the statusbar if there is a prompt. -|<>|Background color of the statusbar if there is a prompt. |<>|Foreground color of the statusbar in insert mode. |<>|Background color of the statusbar in insert mode. |<>|Foreground color of the statusbar in command mode. @@ -268,6 +267,9 @@ |<>|Foreground color an info message. |<>|Background color of an info message. |<>|Border color of an info message. +|<>|Foreground color for prompts. +|<>|Background color for prompts. +|<>|Background color for the selected item in filename prompts. |============== .Quick reference for section ``fonts'' @@ -296,6 +298,7 @@ |<>|Font used for error messages. |<>|Font used for warning messages. |<>|Font used for info messages. +|<>|Font used for prompts. |============== == general @@ -317,7 +320,7 @@ Default: +pass:[smart]+ === startpage The default page(s) to open at the start, separated by commas. -Default: +pass:[https://duckduckgo.com]+ +Default: +pass:[https://start.duckduckgo.com]+ [[general-yank-ignored-url-parameters]] === yank-ignored-url-parameters @@ -706,6 +709,12 @@ Globs are supported, so ';*' will blacklist all keychainsstarting with ';'. Use Default: empty +[[ui-prompt-radius]] +=== prompt-radius +The rounding radius for the edges of prompts. + +Default: +pass:[8]+ + == network Settings related to the network. @@ -1560,7 +1569,7 @@ The file can be in one of the following formats: - One host per line - A zip-file of any of the above, with either only one file, or a file named 'hosts' (with any extension). -Default: +pass:[http://www.malwaredomainlist.com/hostslist/hosts.txt,http://someonewhocares.org/hosts/hosts,http://winhelp2002.mvps.org/hosts.zip,http://malwaredomains.lehigh.edu/files/justdomains.zip,http://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&mimetype=plaintext]+ +Default: +pass:[https://www.malwaredomainlist.com/hostslist/hosts.txt,http://someonewhocares.org/hosts/hosts,http://winhelp2002.mvps.org/hosts.zip,http://malwaredomains.lehigh.edu/files/justdomains.zip,https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&mimetype=plaintext]+ [[content-host-blocking-enabled]] === host-blocking-enabled @@ -1718,7 +1727,7 @@ The searchengine named `DEFAULT` is used when `general -> auto-search` is true a Aliases for commands. By default, no aliases are defined. Example which adds a new command `:qtb` to open qutebrowsers website: -`qtb = open http://www.qutebrowser.org/` +`qtb = open https://www.qutebrowser.org/` == colors Colors used in the UI. @@ -1831,18 +1840,6 @@ Background color of the statusbar. Default: +pass:[black]+ -[[colors-statusbar.fg.prompt]] -=== statusbar.fg.prompt -Foreground color of the statusbar if there is a prompt. - -Default: +pass:[${statusbar.fg}]+ - -[[colors-statusbar.bg.prompt]] -=== statusbar.bg.prompt -Background color of the statusbar if there is a prompt. - -Default: +pass:[darkblue]+ - [[colors-statusbar.fg.insert]] === statusbar.fg.insert Foreground color of the statusbar in insert mode. @@ -2184,6 +2181,24 @@ Border color of an info message. Default: +pass:[#333333]+ +[[colors-prompts.fg]] +=== prompts.fg +Foreground color for prompts. + +Default: +pass:[white]+ + +[[colors-prompts.bg]] +=== prompts.bg +Background color for prompts. + +Default: +pass:[darkblue]+ + +[[colors-prompts.selected.bg]] +=== prompts.selected.bg +Background color for the selected item in filename prompts. + +Default: +pass:[#308cc6]+ + == fonts Fonts used for the UI, with optional style/weight/size. @@ -2322,3 +2337,9 @@ Default: +pass:[8pt ${_monospace}]+ Font used for info messages. Default: +pass:[8pt ${_monospace}]+ + +[[fonts-prompts]] +=== prompts +Font used for prompts. + +Default: +pass:[8pt sans-serif]+ diff --git a/doc/qutebrowser.1.asciidoc b/doc/qutebrowser.1.asciidoc index 2e1c4762c..c0d2b577a 100644 --- a/doc/qutebrowser.1.asciidoc +++ b/doc/qutebrowser.1.asciidoc @@ -7,7 +7,7 @@ :man source: qutebrowser :man manual: qutebrowser manpage :toc: -:homepage: http://www.qutebrowser.org/ +:homepage: https://www.qutebrowser.org/ == NAME qutebrowser - a keyboard-driven, vim-like browser based on PyQt5 and QtWebKit. @@ -143,7 +143,7 @@ https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[mailinglist] at mailto:qutebrowser@lists.qutebrowser.org[] instead. For security bugs, please contact me directly at me@the-compiler.org, GPG ID -http://www.the-compiler.org/pubkey.asc[0xFD55A072]. +https://www.the-compiler.org/pubkey.asc[0xFD55A072]. == COPYRIGHT This program is free software: you can redistribute it and/or modify it under @@ -159,7 +159,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . == RESOURCES -* Website: http://www.qutebrowser.org/ +* Website: https://www.qutebrowser.org/ * Mailinglist: mailto:qutebrowser@lists.qutebrowser.org[] / https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser * Announce-only mailinglist: mailto:qutebrowser-announce@lists.qutebrowser.org[] / diff --git a/misc/cheatsheet.svg b/misc/cheatsheet.svg index 251a6ed0e..29d0a86d7 100644 --- a/misc/cheatsheet.svg +++ b/misc/cheatsheet.svg @@ -2539,7 +2539,7 @@ y="10" style="font-size:10px" />Website: http://www.qutebrowser.org/ Website: https://www.qutebrowser.org/ IRC: #qutebrowser on Freenode{} already exists and is a special file. Write to " + "it anyways?".format(html.escape(self._filename))) + self._ask_confirm_question("Overwrite special file?", txt) else: self._create_fileobj() @@ -963,9 +958,9 @@ class DownloadManager(QObject): # Neither filename nor fileobj were given, prepare a question filename, q = ask_for_filename( - suggested_filename, self._win_id, parent=self, + suggested_filename, parent=self, prompt_download_directory=prompt_download_directory, - ) + url=reply.url()) # User doesn't want to be asked, so just use the download_dir if filename is not None: diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index d28bd6be4..7024e7635 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -22,7 +22,9 @@ import os import collections import netrc +import html +import jinja2 from PyQt5.QtCore import (pyqtSlot, pyqtSignal, PYQT_VERSION, QCoreApplication, QUrl, QByteArray) from PyQt5.QtNetwork import (QNetworkAccessManager, QNetworkReply, QSslError, @@ -207,10 +209,11 @@ class NetworkManager(QNetworkAccessManager): self.setCache(cache) cache.setParent(app) - def _ask(self, text, mode, owner=None): + def _ask(self, title, text, mode, owner=None, default=None): """Ask a blocking question in the statusbar. Args: + title: The title to display to the user. text: The text to display to the user. mode: A PromptMode. owner: An object which will abort the question if destroyed, or @@ -219,24 +222,19 @@ class NetworkManager(QNetworkAccessManager): Return: The answer the user gave or None if the prompt was cancelled. """ - q = usertypes.Question() - q.text = text - q.mode = mode - self.shutting_down.connect(q.abort) + abort_on = [self.shutting_down] if owner is not None: - owner.destroyed.connect(q.abort) + abort_on.append(owner.destroyed) # This might be a generic network manager, e.g. one belonging to a # DownloadManager. In this case, just skip the webview thing. if self._tab_id is not None: tab = objreg.get('tab', scope='tab', window=self._win_id, tab=self._tab_id) - tab.load_started.connect(q.abort) - bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - bridge.ask(q, blocking=True) - q.deleteLater() - return q.answer + abort_on.append(tab.load_started) + + return message.ask(title=title, text=text, mode=mode, + abort_on=abort_on, default=default) def shutdown(self): """Abort all running requests.""" @@ -283,9 +281,19 @@ class NetworkManager(QNetworkAccessManager): return if ssl_strict == 'ask': - err_string = '\n'.join('- ' + err.errorString() for err in errors) - answer = self._ask('SSL errors - continue?\n{}'.format(err_string), - mode=usertypes.PromptMode.yesno, owner=reply) + err_template = jinja2.Template(""" + Errors while loading {{url.toDisplayString()}}:
+
    + {% for err in errors %} +
  • {{err.errorString()}}
  • + {% endfor %} +
+ """.strip()) + msg = err_template.render(url=reply.url(), errors=errors) + + answer = self._ask('SSL errors - continue?', msg, + mode=usertypes.PromptMode.yesno, owner=reply, + default=False) log.webview.debug("Asked for SSL errors, answer {}".format(answer)) if answer: reply.ignoreSslErrors() @@ -343,8 +351,11 @@ class NetworkManager(QNetworkAccessManager): if user is None: # netrc check failed - answer = self._ask("Username ({}):".format(authenticator.realm()), - mode=usertypes.PromptMode.user_pwd, + msg = '{} says:
{}'.format( + html.escape(reply.url().toDisplayString()), + html.escape(authenticator.realm())) + answer = self._ask("Authentication required", + text=msg, mode=usertypes.PromptMode.user_pwd, owner=reply) if answer is not None: user, password = answer.user, answer.password @@ -361,8 +372,11 @@ class NetworkManager(QNetworkAccessManager): authenticator.setUser(user) authenticator.setPassword(password) else: + msg = '{} says:
{}'.format( + html.escape(proxy.hostName()), + html.escape(authenticator.realm())) answer = self._ask( - "Proxy username ({}):".format(authenticator.realm()), + "Proxy authentication required", msg, mode=usertypes.PromptMode.user_pwd) if answer is not None: authenticator.setUser(answer.user) diff --git a/qutebrowser/browser/webkit/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py index b0ee4f89c..93b58bd34 100644 --- a/qutebrowser/browser/webkit/webkitelem.py +++ b/qutebrowser/browser/webkit/webkitelem.py @@ -145,10 +145,11 @@ class WebKitElement(webelem.AbstractWebElement): this.dispatchEvent(event); """.format(javascript.string_escape(text))) - def parent(self): + def _parent(self): + """Get the parent element of this element.""" self._check_vanished() elem = self._elem.parent() - if elem is None: + if elem is None or elem.isNull(): return None return WebKitElement(elem, tab=self._tab) @@ -283,6 +284,18 @@ class WebKitElement(webelem.AbstractWebElement): visible_in_frame = visible_on_screen return all([visible_on_screen, visible_in_frame]) + def remove_blank_target(self): + elem = self + for _ in range(5): + if elem is None: + break + tag = elem.tag_name() + if tag == 'a' or tag == 'area': + if elem.get('target', None) == '_blank': + elem['target'] = '_top' + break + elem = elem._parent() # pylint: disable=protected-access + def get_child_frames(startframe): """Get all children recursively of a given QWebFrame. diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 507a9ba94..61de1bd54 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -26,19 +26,27 @@ import xml.etree.ElementTree from PyQt5.QtCore import (pyqtSlot, Qt, QEvent, QUrl, QPoint, QTimer, QSizeF, QSize) from PyQt5.QtGui import QKeyEvent +from PyQt5.QtWidgets import QApplication from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from PyQt5.QtWebKit import QWebSettings from PyQt5.QtPrintSupport import QPrinter from qutebrowser.browser import browsertab from qutebrowser.browser.webkit import webview, tabhistory, webkitelem +from qutebrowser.browser.webkit.network import proxy, webkitqutescheme from qutebrowser.utils import qtutils, objreg, usertypes, utils, log def init(): """Initialize QtWebKit-specific modules.""" - # FIXME:qtwebengine Move things we don't need with QtWebEngine here. - pass + qapp = QApplication.instance() + + log.init.debug("Initializing proxy...") + proxy.init() + + log.init.debug("Initializing js-bridge...") + js_bridge = webkitqutescheme.JSBridge(qapp) + objreg.register('js-bridge', js_bridge) class WebKitPrinting(browsertab.AbstractPrinting): @@ -624,14 +632,11 @@ class WebKitTab(browsertab.AbstractTab): def run_js_async(self, code, callback=None, *, world=None): if world is not None and world != usertypes.JsWorld.jseval: log.webview.warning("Ignoring world ID {}".format(world)) - result = self._widget.page().mainFrame().evaluateJavaScript(code) + document_element = self._widget.page().mainFrame().documentElement() + result = document_element.evaluateJavaScript(code) if callback is not None: callback(result) - def has_js(self): - settings = QWebSettings.globalSettings() - return settings.testAttribute(QWebSettings.JavascriptEnabled) - def icon(self): return self._widget.icon() diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index c34bc5074..00b253766 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -19,6 +19,7 @@ """The main browser widgets.""" +import html import functools from PyQt5.QtCore import pyqtSlot, pyqtSignal, PYQT_VERSION, Qt, QUrl, QPoint @@ -93,13 +94,19 @@ class BrowserPage(QWebPage): # of a bug in PyQt. # See http://www.riverbankcomputing.com/pipermail/pyqt/2014-June/034385.html - def javaScriptPrompt(self, _frame, msg, default): + def javaScriptPrompt(self, _frame, js_msg, default): """Override javaScriptPrompt to use the statusbar.""" if (self._is_shutting_down or config.get('content', 'ignore-javascript-prompt')): return (False, "") - answer = self._ask("js: {}".format(msg), usertypes.PromptMode.text, - default) + msg = '{} asks:
{}'.format( + html.escape(self.mainFrame().url().toDisplayString()), + html.escape(js_msg)) + answer = message.ask('Javascript prompt', msg, + mode=usertypes.PromptMode.text, + default=default, + abort_on=[self.loadStarted, + self.shutting_down]) if answer is None: return (False, "") else: @@ -134,11 +141,12 @@ class BrowserPage(QWebPage): # QDesktopServices::openUrl with info.url directly - however it # works when we construct a copy of it. url = QUrl(info.url) - msg = "Open external application for {}-link?\nURL: {}".format( - url.scheme(), url.toDisplayString()) + scheme = url.scheme() message.confirm_async( - self._win_id, msg, - functools.partial(QDesktopServices.openUrl, url)) + title="Open external application for {}-link?".format(scheme), + text="URL: {}".format( + html.escape(url.toDisplayString())), + yes_action=functools.partial(QDesktopServices.openUrl, url)) return True elif (info.domain, info.error) in ignored_errors: log.webview.debug("Ignored error on {}: {} (error domain: {}, " @@ -168,11 +176,11 @@ class BrowserPage(QWebPage): log.webview.debug("Error domain: {}, error code: {}".format( info.domain, info.error)) title = "Error loading page: {}".format(urlstr) - html = jinja.render( + error_html = jinja.render( 'error.html', title=title, url=urlstr, error=error_str, icon='', qutescheme=False) - errpage.content = html.encode('utf-8') + errpage.content = error_html.encode('utf-8') errpage.encoding = 'utf-8' return True @@ -196,29 +204,6 @@ class BrowserPage(QWebPage): suggested_file) return True - def _ask(self, text, mode, default=None): - """Ask a blocking question in the statusbar. - - Args: - text: The text to display to the user. - mode: A PromptMode. - default: The default value to display. - - Return: - The answer the user gave or None if the prompt was cancelled. - """ - q = usertypes.Question() - q.text = text - q.mode = mode - q.default = default - self.loadStarted.connect(q.abort) - self.shutting_down.connect(q.abort) - bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - bridge.ask(q, blocking=True) - q.deleteLater() - return q.answer - def _show_pdfjs(self, reply): """Show the reply with pdfjs.""" try: @@ -333,11 +318,6 @@ class BrowserPage(QWebPage): } config_val = config.get(*options[feature]) if config_val == 'ask': - bridge = objreg.get('message-bridge', scope='window', - window=self._win_id) - q = usertypes.Question(bridge) - q.mode = usertypes.PromptMode.yesno - msgs = { QWebPage.Notifications: 'show notifications', QWebPage.Geolocation: 'access your location', @@ -345,30 +325,28 @@ class BrowserPage(QWebPage): host = frame.url().host() if host: - q.text = "Allow the website at {} to {}?".format( - frame.url().host(), msgs[feature]) + text = "Allow the website at {} to {}?".format( + html.escape(frame.url().toDisplayString()), msgs[feature]) else: - q.text = "Allow the website to {}?".format(msgs[feature]) + text = "Allow the website to {}?".format(msgs[feature]) yes_action = functools.partial( self.setFeaturePermission, frame, feature, QWebPage.PermissionGrantedByUser) - q.answered_yes.connect(yes_action) - no_action = functools.partial( self.setFeaturePermission, frame, feature, QWebPage.PermissionDeniedByUser) - q.answered_no.connect(no_action) - q.cancelled.connect(no_action) - self.shutting_down.connect(q.abort) - q.completed.connect(q.deleteLater) - - self.featurePermissionRequestCanceled.connect(functools.partial( - self.on_feature_permission_cancelled, q, frame, feature)) - self.loadStarted.connect(q.abort) - - bridge.ask(q, blocking=False) + question = message.confirm_async(yes_action=yes_action, + no_action=no_action, + cancel_action=no_action, + abort_on=[self.shutting_down, + self.loadStarted], + title='Permission request', + text=text) + self.featurePermissionRequestCanceled.connect( + functools.partial(self.on_feature_permission_cancelled, + question, frame, feature)) elif config_val: self.setFeaturePermission(frame, feature, QWebPage.PermissionGrantedByUser) @@ -469,27 +447,37 @@ class BrowserPage(QWebPage): return super().extension(ext, opt, out) return handler(opt, out) - def javaScriptAlert(self, frame, msg): + def javaScriptAlert(self, frame, js_msg): """Override javaScriptAlert to use the statusbar.""" - log.js.debug("alert: {}".format(msg)) + log.js.debug("alert: {}".format(js_msg)) if config.get('ui', 'modal-js-dialog'): - return super().javaScriptAlert(frame, msg) + return super().javaScriptAlert(frame, js_msg) if (self._is_shutting_down or config.get('content', 'ignore-javascript-alert')): return - self._ask("[js alert] {}".format(msg), usertypes.PromptMode.alert) - def javaScriptConfirm(self, frame, msg): + msg = 'From {}:
{}'.format( + html.escape(self.mainFrame().url().toDisplayString()), + html.escape(js_msg)) + message.ask('Javascript alert', msg, mode=usertypes.PromptMode.alert, + abort_on=[self.loadStarted, self.shutting_down]) + + def javaScriptConfirm(self, frame, js_msg): """Override javaScriptConfirm to use the statusbar.""" - log.js.debug("confirm: {}".format(msg)) + log.js.debug("confirm: {}".format(js_msg)) if config.get('ui', 'modal-js-dialog'): - return super().javaScriptConfirm(frame, msg) + return super().javaScriptConfirm(frame, js_msg) if self._is_shutting_down: return False - ans = self._ask("[js confirm] {}".format(msg), - usertypes.PromptMode.yesno) + + msg = 'From {}:
{}'.format( + html.escape(self.mainFrame().url().toDisplayString()), + html.escape(js_msg)) + ans = message.ask('Javascript confirm', msg, + mode=usertypes.PromptMode.yesno, + abort_on=[self.loadStarted, self.shutting_down]) return bool(ans) def javaScriptConsoleMessage(self, msg, line, source): diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 5d98c9166..a2bc3b50d 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -427,9 +427,16 @@ class Command: if isinstance(typ, tuple): raise TypeError("{}: Legacy tuple type annotation!".format( self.name)) - elif issubclass(typ, typing.Union): + elif type(typ) is type(typing.Union): # flake8: disable=E721 # this is... slightly evil, I know - types = list(typ.__union_params__) # pylint: disable=no-member + # We also can't use isinstance here because typing.Union doesn't + # support that. + # pylint: disable=no-member,useless-suppression + try: + types = list(typ.__union_params__) + except AttributeError: + types = list(typ.__args__) + # pylint: enable=no-member,useless-suppression if param.default is not inspect.Parameter.empty: types.append(type(param.default)) choices = self.get_arg_info(param).choices diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 8ee27918a..7c8ba98c5 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -391,6 +391,8 @@ class ConfigManager(QObject): ('colors', 'statusbar.bg.error'): 'messages.bg.error', ('colors', 'statusbar.fg.warning'): 'messages.fg.warning', ('colors', 'statusbar.bg.warning'): 'messages.bg.warning', + ('colors', 'statusbar.fg.prompt'): 'prompts.fg', + ('colors', 'statusbar.bg.prompt'): 'prompts.bg', } DELETED_OPTIONS = [ ('colors', 'tab.separator'), diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 5cd9136c6..375136eef 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -90,7 +90,7 @@ SECTION_DESC = { "Aliases for commands.\n" "By default, no aliases are defined. Example which adds a new command " "`:qtb` to open qutebrowsers website:\n\n" - "`qtb = open http://www.qutebrowser.org/`"), + "`qtb = open https://www.qutebrowser.org/`"), 'colors': ( "Colors used in the UI.\n" "A value can be in one of the following format:\n\n" @@ -136,7 +136,8 @@ def data(readonly=False): "Whether to find text on a page case-insensitively."), ('startpage', - SettingValue(typ.List(typ.String()), 'https://duckduckgo.com'), + SettingValue(typ.List(typ.String()), + 'https://start.duckduckgo.com'), "The default page(s) to open at the start, separated by commas."), ('yank-ignored-url-parameters', @@ -383,6 +384,10 @@ def data(readonly=False): "Globs are supported, so ';*' will blacklist all keychains" "starting with ';'. Use '*' to disable keyhints"), + ('prompt-radius', + SettingValue(typ.Int(minval=0), '8'), + "The rounding radius for the edges of prompts."), + readonly=readonly )), @@ -871,11 +876,11 @@ def data(readonly=False): ('host-block-lists', SettingValue( typ.List(typ.Url(), none_ok=True), - 'http://www.malwaredomainlist.com/hostslist/hosts.txt,' + 'https://www.malwaredomainlist.com/hostslist/hosts.txt,' 'http://someonewhocares.org/hosts/hosts,' 'http://winhelp2002.mvps.org/hosts.zip,' 'http://malwaredomains.lehigh.edu/files/justdomains.zip,' - 'http://pgl.yoyo.org/adservers/serverlist.php?' + 'https://pgl.yoyo.org/adservers/serverlist.php?' 'hostformat=hosts&mimetype=plaintext'), "List of URLs of lists which contain hosts to block.\n\n" "The file can be in one of the following formats:\n\n" @@ -1074,14 +1079,6 @@ def data(readonly=False): SettingValue(typ.QssColor(), 'black'), "Background color of the statusbar."), - ('statusbar.fg.prompt', - SettingValue(typ.QssColor(), '${statusbar.fg}'), - "Foreground color of the statusbar if there is a prompt."), - - ('statusbar.bg.prompt', - SettingValue(typ.QssColor(), 'darkblue'), - "Background color of the statusbar if there is a prompt."), - ('statusbar.fg.insert', SettingValue(typ.QssColor(), '${statusbar.fg}'), "Foreground color of the statusbar in insert mode."), @@ -1305,6 +1302,18 @@ def data(readonly=False): SettingValue(typ.QssColor(), '#333333'), "Border color of an info message."), + ('prompts.fg', + SettingValue(typ.QssColor(), 'white'), + "Foreground color for prompts."), + + ('prompts.bg', + SettingValue(typ.QssColor(), 'darkblue'), + "Background color for prompts."), + + ('prompts.selected.bg', + SettingValue(typ.QssColor(), '#308cc6'), + "Background color for the selected item in filename prompts."), + readonly=readonly )), @@ -1406,6 +1415,10 @@ def data(readonly=False): SettingValue(typ.Font(), DEFAULT_FONT_SIZE + ' ${_monospace}'), "Font used for info messages."), + ('prompts', + SettingValue(typ.Font(), DEFAULT_FONT_SIZE + ' sans-serif'), + "Font used for prompts."), + readonly=readonly )), ]) @@ -1673,6 +1686,8 @@ KEY_DATA = collections.OrderedDict([ ('prompt-accept yes', ['y']), ('prompt-accept no', ['n']), ('prompt-open-download', ['']), + ('prompt-item-focus prev', ['', '']), + ('prompt-item-focus next', ['', '']), ])), ('command,prompt', collections.OrderedDict([ diff --git a/qutebrowser/config/style.py b/qutebrowser/config/style.py index 92c893c45..b2697daac 100644 --- a/qutebrowser/config/style.py +++ b/qutebrowser/config/style.py @@ -46,7 +46,7 @@ def get_stylesheet(template_str): config=objreg.get('config')) -def set_register_stylesheet(obj, *, generator=None): +def set_register_stylesheet(obj): """Set the stylesheet for an object based on it's STYLESHEET attribute. Also, register an update when the config is changed. @@ -54,23 +54,20 @@ def set_register_stylesheet(obj, *, generator=None): Args: obj: The object to set the stylesheet for and register. Must have a STYLESHEET attribute. - generator: If set, call the given function to dynamically generate a - stylesheet instead. """ - stylesheet = generator() if generator is not None else obj.STYLESHEET - qss = get_stylesheet(stylesheet) + qss = get_stylesheet(obj.STYLESHEET) log.config.vdebug("stylesheet for {}: {}".format( obj.__class__.__name__, qss)) obj.setStyleSheet(qss) objreg.get('config').changed.connect( - functools.partial(_update_stylesheet, obj, generator=generator)) + functools.partial(_update_stylesheet, obj)) -def _update_stylesheet(obj, *, generator): +def _update_stylesheet(obj): """Update the stylesheet for obj.""" + get_stylesheet.cache_clear() if not sip.isdeleted(obj): - stylesheet = generator() if generator is not None else obj.STYLESHEET - obj.setStyleSheet(get_stylesheet(stylesheet)) + obj.setStyleSheet(get_stylesheet(obj.STYLESHEET)) class ColorDict(collections.UserDict): diff --git a/qutebrowser/html/no_pdfjs.html b/qutebrowser/html/no_pdfjs.html index fa543c05b..ddaa2c257 100644 --- a/qutebrowser/html/no_pdfjs.html +++ b/qutebrowser/html/no_pdfjs.html @@ -90,6 +90,9 @@ li {
  • If you have installed a packaged version of qutebrowser, make sure the required packages for pdf.js are also installed. +
    + The package is named pdfjs on Archlinux (AUR) and + libjs-pdf on Debian.
  • diff --git a/qutebrowser/javascript/webelem.js b/qutebrowser/javascript/webelem.js index 3e38c8adb..602e6b261 100644 --- a/qutebrowser/javascript/webelem.js +++ b/qutebrowser/javascript/webelem.js @@ -155,5 +155,23 @@ window._qutebrowser.webelem = (function() { return serialize_elem(elem); }; + funcs.set_attribute = function(id, name, value) { + elements[id].setAttribute(name, value); + }; + + funcs.remove_blank_target = function(id) { + var elem = elements[id]; + while (elem !== null) { + var tag = elem.tagName.toLowerCase(); + if (tag === "a" || tag === "area") { + if (elem.getAttribute("target") === "_blank") { + elem.setAttribute("target", "_top"); + } + break; + } + elem = elem.parentElement; + } + }; + return funcs; })(); diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index ebc4f28ad..0e5a5c069 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -24,13 +24,14 @@ import base64 import itertools import functools +import jinja2 from PyQt5.QtCore import pyqtSlot, QRect, QPoint, QTimer, Qt from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QSizePolicy from qutebrowser.commands import runners, cmdutils from qutebrowser.config import config from qutebrowser.utils import message, log, usertypes, qtutils, objreg, utils -from qutebrowser.mainwindow import tabbedbrowser, messageview +from qutebrowser.mainwindow import tabbedbrowser, messageview, prompt from qutebrowser.mainwindow.statusbar import bar from qutebrowser.completion import completionwidget, completer from qutebrowser.keyinput import modeman @@ -175,17 +176,24 @@ class MainWindow(QWidget): self._init_completion() + log.init.debug("Initializing modes...") + modeman.init(self.win_id, self) + self._commandrunner = runners.CommandRunner(self.win_id, partial_match=True) self._keyhint = keyhintwidget.KeyHintView(self.win_id, self) - self._overlays.append((self._keyhint, self._keyhint.update_geometry)) + self._add_overlay(self._keyhint, self._keyhint.update_geometry) self._messageview = messageview.MessageView(parent=self) - self._overlays.append((self._messageview, - self._messageview.update_geometry)) + self._add_overlay(self._messageview, self._messageview.update_geometry) - log.init.debug("Initializing modes...") - modeman.init(self.win_id, self) + self._prompt_container = prompt.PromptContainer(self.win_id, self) + self._add_overlay(self._prompt_container, + self._prompt_container.update_geometry, + centered=True, padding=10) + objreg.register('prompt-container', self._prompt_container, + scope='window', window=self.win_id) + self._prompt_container.hide() if geometry is not None: self._load_geometry(geometry) @@ -206,36 +214,40 @@ class MainWindow(QWidget): objreg.get("app").new_window.emit(self) - def _update_overlay_geometry(self, widget=None): - """Reposition/resize the given overlay. + def _add_overlay(self, widget, signal, *, centered=False, padding=0): + self._overlays.append((widget, signal, centered, padding)) - If no widget is given, reposition/resize all overlays. - """ - if widget is None: - for w, _signal in self._overlays: - self._update_overlay_geometry(w) - return + def _update_overlay_geometries(self): + """Update the size/position of all overlays.""" + for w, _signal, centered, padding in self._overlays: + self._update_overlay_geometry(w, centered, padding) + def _update_overlay_geometry(self, widget, centered, padding): + """Reposition/resize the given overlay.""" if not widget.isVisible(): return size_hint = widget.sizeHint() if widget.sizePolicy().horizontalPolicy() == QSizePolicy.Expanding: - width = self.width() + width = self.width() - 2 * padding + left = padding else: width = size_hint.width() + left = (self.width() - size_hint.width()) / 2 if centered else 0 + height_padding = 20 status_position = config.get('ui', 'status-position') if status_position == 'bottom': top = self.height() - self.status.height() - size_hint.height() top = qtutils.check_overflow(top, 'int', fatal=False) - topleft = QPoint(0, top) - bottomright = QPoint(width, self.status.geometry().top()) + topleft = QPoint(left, max(height_padding, top)) + bottomright = QPoint(left + width, self.status.geometry().top()) elif status_position == 'top': - topleft = self.status.geometry().bottomLeft() + topleft = QPoint(left, self.status.geometry().bottom()) bottom = self.status.height() + size_hint.height() bottom = qtutils.check_overflow(bottom, 'int', fatal=False) - bottomright = QPoint(width, bottom) + bottomright = QPoint(left + width, + min(self.height() - height_padding, bottom)) else: raise ValueError("Invalid position {}!".format(status_position)) @@ -261,8 +273,7 @@ class MainWindow(QWidget): completer_obj.on_selection_changed) objreg.register('completion', self._completion, scope='window', window=self.win_id) - self._overlays.append((self._completion, - self._completion.update_geometry)) + self._add_overlay(self._completion, self._completion.update_geometry) def _init_command_dispatcher(self): dispatcher = commands.CommandDispatcher(self.win_id, @@ -282,12 +293,12 @@ class MainWindow(QWidget): if section != 'ui': return if option == 'statusbar-padding': - self._update_overlay_geometry() + self._update_overlay_geometries() elif option == 'downloads-position': self._add_widgets() elif option == 'status-position': self._add_widgets() - self._update_overlay_geometry() + self._update_overlay_geometries() def _add_widgets(self): """Add or readd all widgets to the VBox.""" @@ -350,10 +361,11 @@ class MainWindow(QWidget): def _connect_overlay_signals(self): """Connect the resize signal and resize everything once.""" - for widget, signal in self._overlays: + for widget, signal, centered, padding in self._overlays: signal.connect( - functools.partial(self._update_overlay_geometry, widget)) - self._update_overlay_geometry(widget) + functools.partial(self._update_overlay_geometry, widget, + centered, padding)) + self._update_overlay_geometry(widget, centered, padding) def _set_default_geometry(self): """Set some sensible default geometry.""" @@ -374,7 +386,6 @@ class MainWindow(QWidget): cmd = self._get_object('status-command') message_bridge = self._get_object('message-bridge') mode_manager = self._get_object('mode-manager') - prompter = self._get_object('prompter') # misc self.tabbed_browser.close_window.connect(self.close) @@ -384,7 +395,7 @@ class MainWindow(QWidget): mode_manager.entered.connect(status.on_mode_entered) mode_manager.left.connect(status.on_mode_left) mode_manager.left.connect(cmd.on_mode_left) - mode_manager.left.connect(prompter.on_mode_left) + mode_manager.left.connect(message.global_bridge.mode_left) # commands keyparsers[usertypes.KeyMode.normal].keystring_updated.connect( @@ -407,9 +418,6 @@ class MainWindow(QWidget): message_bridge.s_set_text.connect(status.set_text) message_bridge.s_maybe_reset_text.connect(status.txt.maybe_reset_text) - message_bridge.s_set_cmd_text.connect(cmd.set_cmd_text) - message_bridge.s_question.connect(prompter.ask_question, - Qt.DirectConnection) # statusbar tabs.current_tab_changed.connect(status.prog.on_tab_changed) @@ -459,7 +467,7 @@ class MainWindow(QWidget): e: The QResizeEvent """ super().resizeEvent(e) - self._update_overlay_geometry() + self._update_overlay_geometries() self._downloadview.updateGeometry() self.tabbed_browser.tabBar().refresh() @@ -507,10 +515,17 @@ class MainWindow(QWidget): "download is" if download_count == 1 else "downloads are")) # Process all quit messages that user must confirm if quit_texts or 'always' in confirm_quit: - text = '\n'.join(['Really quit?'] + quit_texts) - confirmed = message.ask(self.win_id, text, - usertypes.PromptMode.yesno, + msg = jinja2.Template(""" +
      + {% for text in quit_texts %} +
    • {{text}}
    • + {% endfor %} +
    + """.strip()).render(quit_texts=quit_texts) + confirmed = message.ask('Really quit?', msg, + mode=usertypes.PromptMode.yesno, default=True) + # Stop asking if the user cancels if not confirmed: log.destroy.debug("Cancelling closing of window {}".format( diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py new file mode 100644 index 000000000..7435a46a7 --- /dev/null +++ b/qutebrowser/mainwindow/prompt.py @@ -0,0 +1,829 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Showing prompts above the statusbar.""" + +import os.path +import html +import collections + +import sip +from PyQt5.QtCore import (pyqtSlot, pyqtSignal, Qt, QTimer, QDir, QModelIndex, + QItemSelectionModel, QObject) +from PyQt5.QtWidgets import (QWidget, QGridLayout, QVBoxLayout, QLineEdit, + QLabel, QFileSystemModel, QTreeView, QSizePolicy) + +from qutebrowser.config import style +from qutebrowser.utils import usertypes, log, utils, qtutils, objreg, message +from qutebrowser.keyinput import modeman +from qutebrowser.commands import cmdutils, cmdexc + + +prompt_queue = None + + +AuthTuple = collections.namedtuple('AuthTuple', ['user', 'password']) + + +class Error(Exception): + + """Base class for errors in this module.""" + + +class UnsupportedOperationError(Exception): + + """Raised when the prompt class doesn't support the requested operation.""" + + +class PromptQueue(QObject): + + """Global manager and queue for upcoming prompts. + + The way in which multiple questions are handled deserves some explanation. + + If a question is blocking, we *need* to ask it immediately, and can't wait + for previous questions to finish. We could theoretically ask a blocking + question inside of another blocking one, so in ask_question we simply save + the current question on the stack, let the user answer the *most recent* + question, and then restore the previous state. + + With a non-blocking question, things are a bit easier. We simply add it to + self._queue if we're still busy handling another question, since it can be + answered at any time. + + In either case, as soon as we finished handling a question, we call + _pop_later() which schedules a _pop to ask the next question in _queue. We + schedule it rather than doing it immediately because then the order of how + things happen is clear, e.g. on_mode_left can't happen after we already set + up the *new* question. + + Attributes: + _shutting_down: Whether we're currently shutting down the prompter and + should ignore future questions to avoid segfaults. + _loops: A list of local EventLoops to spin in when blocking. + _queue: A deque of waiting questions. + _question: The current Question object if we're handling a question. + + Signals: + show_prompts: Emitted with a Question object when prompts should be + shown. + """ + + show_prompts = pyqtSignal(usertypes.Question) + + def __init__(self, parent=None): + super().__init__(parent) + self._question = None + self._shutting_down = False + self._loops = [] + self._queue = collections.deque() + message.global_bridge.mode_left.connect(self._on_mode_left) + + def __repr__(self): + return utils.get_repr(self, loops=len(self._loops), + queue=len(self._queue), question=self._question) + + def _pop_later(self): + """Helper to call self._pop as soon as everything else is done.""" + QTimer.singleShot(0, self._pop) + + def _pop(self): + """Pop a question from the queue and ask it, if there are any.""" + log.prompt.debug("Popping from queue {}".format(self._queue)) + if self._queue: + question = self._queue.popleft() + if not sip.isdeleted(question): + # the question could already be deleted, e.g. by a cancelled + # download. See + # https://github.com/The-Compiler/qutebrowser/issues/415 + self.ask_question(question, blocking=False) + + def shutdown(self): + """Cancel all blocking questions. + + Quits and removes all running event loops. + + Return: + True if loops needed to be aborted, + False otherwise. + """ + log.prompt.debug("Shutting down with loops {}".format(self._loops)) + self._shutting_down = True + if self._loops: + for loop in self._loops: + loop.quit() + loop.deleteLater() + return True + else: + return False + + @pyqtSlot(usertypes.Question, bool) + def ask_question(self, question, blocking): + """Display a prompt for a given question. + + Args: + question: The Question object to ask. + blocking: If True, this function blocks and returns the result. + + Return: + The answer of the user when blocking=True. + None if blocking=False. + """ + log.prompt.debug("Asking question {}, blocking {}, loops {}, queue " + "{}".format(question, blocking, self._loops, + self._queue)) + + if self._shutting_down: + # If we're currently shutting down we have to ignore this question + # to avoid segfaults - see + # https://github.com/The-Compiler/qutebrowser/issues/95 + log.prompt.debug("Ignoring question because we're shutting down.") + question.abort() + return None + + if self._question is not None and not blocking: + # We got an async question, but we're already busy with one, so we + # just queue it up for later. + log.prompt.debug("Adding {} to queue.".format(question)) + self._queue.append(question) + return + + if blocking: + # If we're blocking we save the old question on the stack, so we + # can restore it after exec, if exec gets called multiple times. + log.prompt.debug("New question is blocking, saving {}".format( + self._question)) + old_question = self._question + if old_question is not None: + old_question.interrupted = True + + self._question = question + self.show_prompts.emit(question) + + if blocking: + loop = qtutils.EventLoop() + self._loops.append(loop) + loop.destroyed.connect(lambda: self._loops.remove(loop)) + question.completed.connect(loop.quit) + question.completed.connect(loop.deleteLater) + log.prompt.debug("Starting loop.exec_() for {}".format(question)) + loop.exec_() + log.prompt.debug("Ending loop.exec_() for {}".format(question)) + + log.prompt.debug("Restoring old question {}".format(old_question)) + self._question = old_question + self.show_prompts.emit(old_question) + if old_question is None: + # Nothing left to restore, so we can go back to popping async + # questions. + if self._queue: + self._pop_later() + + return question.answer + else: + question.completed.connect(self._pop_later) + + @pyqtSlot(usertypes.KeyMode) + def _on_mode_left(self, mode): + """Abort question when a prompt mode was left.""" + if mode not in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: + return + if self._question is None: + return + + log.prompt.debug("Left mode {}, hiding {}".format( + mode, self._question)) + self.show_prompts.emit(None) + if self._question.answer is None and not self._question.is_aborted: + log.prompt.debug("Cancelling {} because {} was left".format( + self._question, mode)) + self._question.cancel() + self._question = None + + +class PromptContainer(QWidget): + + """Container for prompts to be shown above the statusbar. + + This is a per-window object, however each window shows the same prompt. + + Attributes: + _layout: The layout used to show prompts in. + _win_id: The window ID this object is associated with. + + Signals: + update_geometry: Emitted when the geometry should be updated. + """ + + STYLESHEET = """ + {% set prompt_radius = config.get('ui', 'prompt-radius') %} + QWidget#PromptContainer { + {% if config.get('ui', 'status-position') == 'top' %} + border-bottom-left-radius: {{ prompt_radius }}px; + border-bottom-right-radius: {{ prompt_radius }}px; + {% else %} + border-top-left-radius: {{ prompt_radius }}px; + border-top-right-radius: {{ prompt_radius }}px; + {% endif %} + } + + QWidget { + font: {{ font['prompts'] }}; + color: {{ color['prompts.fg'] }}; + background-color: {{ color['prompts.bg'] }}; + } + + QTreeView { + selection-background-color: {{ color['prompts.selected.bg'] }}; + } + + QTreeView::item:selected, QTreeView::item:selected:hover { + background-color: {{ color['prompts.selected.bg'] }}; + } + """ + update_geometry = pyqtSignal() + + def __init__(self, win_id, parent=None): + super().__init__(parent) + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(10, 10, 10, 10) + self._win_id = win_id + self._prompt = None + + self.setObjectName('PromptContainer') + self.setAttribute(Qt.WA_StyledBackground, True) + style.set_register_stylesheet(self) + + message.global_bridge.prompt_done.connect(self._on_prompt_done) + prompt_queue.show_prompts.connect(self._on_show_prompts) + message.global_bridge.mode_left.connect(self._on_global_mode_left) + + def __repr__(self): + return utils.get_repr(self, win_id=self._win_id) + + @pyqtSlot(usertypes.Question) + def _on_show_prompts(self, question): + """Show a prompt for the given question. + + Args: + question: A Question object or None. + """ + item = self._layout.takeAt(0) + if item is not None: + widget = item.widget() + log.prompt.debug("Deleting old prompt {}".format(widget)) + widget.hide() + widget.deleteLater() + + if question is None: + log.prompt.debug("No prompts left, hiding prompt container.") + self._prompt = None + self.hide() + return + + classes = { + usertypes.PromptMode.yesno: YesNoPrompt, + usertypes.PromptMode.text: LineEditPrompt, + usertypes.PromptMode.user_pwd: AuthenticationPrompt, + usertypes.PromptMode.download: DownloadFilenamePrompt, + usertypes.PromptMode.alert: AlertPrompt, + } + klass = classes[question.mode] + prompt = klass(question) + + log.prompt.debug("Displaying prompt {}".format(prompt)) + self._prompt = prompt + + if not question.interrupted: + # If this question was interrupted, we already connected the signal + question.aborted.connect( + lambda: modeman.maybe_leave(self._win_id, prompt.KEY_MODE, + 'aborted')) + modeman.enter(self._win_id, prompt.KEY_MODE, 'question asked') + + self.setSizePolicy(prompt.sizePolicy()) + self._layout.addWidget(prompt) + prompt.show() + self.show() + prompt.setFocus() + self.update_geometry.emit() + + @pyqtSlot(usertypes.KeyMode) + def _on_prompt_done(self, key_mode): + """Leave the prompt mode in this window if a question was answered.""" + modeman.maybe_leave(self._win_id, key_mode, ':prompt-accept') + + @pyqtSlot(usertypes.KeyMode) + def _on_global_mode_left(self, mode): + """Leave prompt/yesno mode in this window if it was left elsewhere. + + This ensures no matter where a prompt was answered, we leave the prompt + mode and dispose of the prompt object in every window. + """ + if mode not in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: + return + modeman.maybe_leave(self._win_id, mode, 'left in other window') + item = self._layout.takeAt(0) + if item is not None: + widget = item.widget() + log.prompt.debug("Deleting prompt {}".format(widget)) + widget.hide() + widget.deleteLater() + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.prompt, + usertypes.KeyMode.yesno]) + def prompt_accept(self, value=None): + """Accept the current prompt. + + // + + This executes the next action depending on the question mode, e.g. asks + for the password or leaves the mode. + + Args: + value: If given, uses this value instead of the entered one. + For boolean prompts, "yes"/"no" are accepted as value. + """ + question = self._prompt.question + try: + done = self._prompt.accept(value) + except Error as e: + raise cmdexc.CommandError(str(e)) + if done: + message.global_bridge.prompt_done.emit(self._prompt.KEY_MODE) + question.done() + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.yesno], + deprecated='Use :prompt-accept yes instead!') + def prompt_yes(self): + """Answer yes to a yes/no prompt.""" + self.prompt_accept('yes') + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.yesno], + deprecated='Use :prompt-accept no instead!') + def prompt_no(self): + """Answer no to a yes/no prompt.""" + self.prompt_accept('no') + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.prompt], maxsplit=0) + def prompt_open_download(self, cmdline: str=None): + """Immediately open a download. + + If no specific command is given, this will use the system's default + application to open the file. + + Args: + cmdline: The command which should be used to open the file. A `{}` + is expanded to the temporary file name. If no `{}` is + present, the filename is automatically appended to the + cmdline. + """ + try: + self._prompt.download_open(cmdline) + except UnsupportedOperationError: + pass + + @cmdutils.register(instance='prompt-container', hide=True, scope='window', + modes=[usertypes.KeyMode.prompt]) + @cmdutils.argument('which', choices=['next', 'prev']) + def prompt_item_focus(self, which): + """Shift the focus of the prompt file completion menu to another item. + + Args: + which: 'next', 'prev' + """ + try: + self._prompt.item_focus(which) + except UnsupportedOperationError: + pass + + +class LineEdit(QLineEdit): + + """A line edit used in prompts.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setStyleSheet(""" + QLineEdit { + border: 1px solid grey; + background-color: transparent; + } + """) + self.setAttribute(Qt.WA_MacShowFocusRect, False) + + def keyPressEvent(self, e): + """Override keyPressEvent to paste primary selection on Shift + Ins.""" + if e.key() == Qt.Key_Insert and e.modifiers() == Qt.ShiftModifier: + try: + text = utils.get_clipboard(selection=True) + except utils.ClipboardError: # pragma: no cover + pass + else: + e.accept() + self.insert(text) + return + super().keyPressEvent(e) + + def __repr__(self): + return utils.get_repr(self) + + +class _BasePrompt(QWidget): + + """Base class for all prompts.""" + + KEY_MODE = usertypes.KeyMode.prompt + + def __init__(self, question, parent=None): + super().__init__(parent) + self.question = question + self._vbox = QVBoxLayout(self) + self._vbox.setSpacing(15) + self._key_grid = None + + def __repr__(self): + return utils.get_repr(self, question=self.question, constructor=True) + + def _init_texts(self, question): + assert question.title is not None, question + title = '{}'.format( + html.escape(question.title)) + title_label = QLabel(title, self) + self._vbox.addWidget(title_label) + if question.text is not None: + # Not doing any HTML escaping here as the text can be formatted + text_label = QLabel(question.text) + self._vbox.addWidget(text_label) + + def _init_key_label(self): + assert self._key_grid is None, self._key_grid + self._key_grid = QGridLayout() + self._key_grid.setVerticalSpacing(0) + + key_config = objreg.get('key-config') + # The bindings are all in the 'prompt' mode, even for yesno prompts + all_bindings = key_config.get_reverse_bindings_for('prompt') + labels = [] + + for cmd, text in self._allowed_commands(): + bindings = all_bindings.get(cmd, []) + if bindings: + binding = None + preferred = ['', ''] + for pref in preferred: + if pref in bindings: + binding = pref + if binding is None: + binding = bindings[0] + key_label = QLabel('{}'.format(html.escape(binding))) + text_label = QLabel(text) + labels.append((key_label, text_label)) + + for i, (key_label, text_label) in enumerate(labels): + self._key_grid.addWidget(key_label, i, 0) + self._key_grid.addWidget(text_label, i, 1) + + self._vbox.addLayout(self._key_grid) + + def accept(self, value=None): + raise NotImplementedError + + def download_open(self, _cmdline): + """Open the download directly if this is a download prompt.""" + raise UnsupportedOperationError + + def item_focus(self, _which): + """Switch to next file item if this is a filename prompt..""" + raise UnsupportedOperationError + + def _allowed_commands(self): + """Get the commands we could run as response to this message.""" + raise NotImplementedError + + +class LineEditPrompt(_BasePrompt): + + """A prompt for a single text value.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._lineedit = LineEdit(self) + self._init_texts(question) + self._vbox.addWidget(self._lineedit) + if question.default: + self._lineedit.setText(question.default) + self.setFocusProxy(self._lineedit) + self._init_key_label() + + def accept(self, value=None): + text = value if value is not None else self._lineedit.text() + self.question.answer = text + return True + + def _allowed_commands(self): + return [('prompt-accept', 'Accept'), ('leave-mode', 'Abort')] + + +class FilenamePrompt(_BasePrompt): + + """A prompt for a filename.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + self._init_fileview() + self._set_fileview_root(question.default) + + self._lineedit = LineEdit(self) + if question.default: + self._lineedit.setText(question.default) + self._lineedit.textEdited.connect(self._set_fileview_root) + self._vbox.addWidget(self._lineedit) + + self.setFocusProxy(self._lineedit) + self._init_key_label() + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + @pyqtSlot(str) + def _set_fileview_root(self, path, *, tabbed=False): + """Set the root path for the file display.""" + separators = os.sep + if os.altsep is not None: + separators += os.altsep + + dirname = os.path.dirname(path) + + try: + if not path: + pass + elif path in separators and os.path.isdir(path): + # Input "/" -> don't strip anything + pass + elif path[-1] in separators and os.path.isdir(path): + # Input like /foo/bar/ -> show /foo/bar/ contents + path = path.rstrip(separators) + elif os.path.isdir(dirname) and not tabbed: + # Input like /foo/ba -> show /foo contents + path = dirname + else: + return + except OSError: + log.prompt.exception("Failed to get directory information") + return + + root = self._file_model.setRootPath(path) + self._file_view.setRootIndex(root) + + @pyqtSlot(QModelIndex) + def _insert_path(self, index, *, clicked=True): + """Handle an element selection. + + Args: + index: The QModelIndex of the selected element. + clicked: Whether the element was clicked. + """ + path = os.path.normpath(self._file_model.filePath(index)) + if clicked: + path += os.sep + else: + # On Windows, when we have C:\foo and tab over .., we get C:\ + path = path.rstrip(os.sep) + + log.prompt.debug('Inserting path {}'.format(path)) + self._lineedit.setText(path) + self._lineedit.setFocus() + self._set_fileview_root(path, tabbed=True) + if clicked: + # Avoid having a ..-subtree highlighted + self._file_view.setCurrentIndex(QModelIndex()) + + def _init_fileview(self): + self._file_view = QTreeView(self) + self._file_model = QFileSystemModel(self) + self._file_view.setModel(self._file_model) + self._file_view.clicked.connect(self._insert_path) + self._vbox.addWidget(self._file_view) + # Only show name + self._file_view.setHeaderHidden(True) + for col in range(1, 4): + self._file_view.setColumnHidden(col, True) + # Nothing selected initially + self._file_view.setCurrentIndex(QModelIndex()) + # The model needs to be sorted so we get the correct first/last index + self._file_model.directoryLoaded.connect( + lambda: self._file_model.sort(0)) + + def accept(self, value=None): + text = value if value is not None else self._lineedit.text() + self.question.answer = text + return True + + def item_focus(self, which): + # This duplicates some completion code, but I don't see a nicer way... + assert which in ['prev', 'next'], which + selmodel = self._file_view.selectionModel() + + parent = self._file_view.rootIndex() + first_index = self._file_model.index(0, 0, parent) + row = self._file_model.rowCount(parent) - 1 + last_index = self._file_model.index(row, 0, parent) + + if not first_index.isValid(): + # No entries + return + + assert last_index.isValid() + + idx = selmodel.currentIndex() + if not idx.isValid(): + # No item selected yet + idx = last_index if which == 'prev' else first_index + elif which == 'prev': + idx = self._file_view.indexAbove(idx) + else: + assert which == 'next', which + idx = self._file_view.indexBelow(idx) + + # wrap around if we arrived at beginning/end + if not idx.isValid(): + idx = last_index if which == 'prev' else first_index + + selmodel.setCurrentIndex( + idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) + self._insert_path(idx, clicked=False) + + def _allowed_commands(self): + return [('prompt-accept', 'Accept'), ('leave-mode', 'Abort')] + + +class DownloadFilenamePrompt(FilenamePrompt): + + """A prompt for a filename for downloads.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._file_model.setFilter(QDir.AllDirs | QDir.Drives | QDir.NoDot) + + def accept(self, value=None): + text = value if value is not None else self._lineedit.text() + self.question.answer = usertypes.FileDownloadTarget(text) + return True + + def download_open(self, cmdline): + self.question.answer = usertypes.OpenFileDownloadTarget(cmdline) + self.question.done() + message.global_bridge.prompt_done.emit(self.KEY_MODE) + + def _allowed_commands(self): + cmds = [ + ('prompt-accept', 'Accept'), + ('leave-mode', 'Abort'), + ('prompt-open-download', "Open download"), + ] + return cmds + + +class AuthenticationPrompt(_BasePrompt): + + """A prompt for username/password.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + + user_label = QLabel("Username:", self) + self._user_lineedit = LineEdit(self) + + password_label = QLabel("Password:", self) + self._password_lineedit = LineEdit(self) + self._password_lineedit.setEchoMode(QLineEdit.Password) + + grid = QGridLayout() + grid.addWidget(user_label, 1, 0) + grid.addWidget(self._user_lineedit, 1, 1) + grid.addWidget(password_label, 2, 0) + grid.addWidget(self._password_lineedit, 2, 1) + self._vbox.addLayout(grid) + self._init_key_label() + + assert not question.default, question.default + self.setFocusProxy(self._user_lineedit) + + def accept(self, value=None): + if value is not None: + if ':' not in value: + raise Error("Value needs to be in the format " + "username:password, but {} was given".format( + value)) + username, password = value.split(':', maxsplit=1) + self.question.answer = AuthTuple(username, password) + return True + elif self._user_lineedit.hasFocus(): + # Earlier, tab was bound to :prompt-accept, so to still support + # that we simply switch the focus when tab was pressed. + self._password_lineedit.setFocus() + return False + else: + self.question.answer = AuthTuple(self._user_lineedit.text(), + self._password_lineedit.text()) + return True + + def item_focus(self, which): + """Support switching between fields with tab.""" + assert which in ['prev', 'next'], which + if which == 'next' and self._user_lineedit.hasFocus(): + self._password_lineedit.setFocus() + elif which == 'prev' and self._password_lineedit.hasFocus(): + self._user_lineedit.setFocus() + + def _allowed_commands(self): + return [('prompt-accept', "Accept"), + ('leave-mode', "Abort")] + + +class YesNoPrompt(_BasePrompt): + + """A prompt with yes/no answers.""" + + KEY_MODE = usertypes.KeyMode.yesno + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + self._init_key_label() + + def accept(self, value=None): + if value is None: + if self.question.default is None: + raise Error("No default value was set for this question!") + self.question.answer = self.question.default + elif value == 'yes': + self.question.answer = True + elif value == 'no': + self.question.answer = False + else: + raise Error("Invalid value {} - expected yes/no!".format(value)) + return True + + def _allowed_commands(self): + cmds = [ + ('prompt-accept yes', "Yes"), + ('prompt-accept no', "No"), + ] + + if self.question.default is not None: + assert self.question.default in [True, False] + default = 'yes' if self.question.default else 'no' + cmds.append(('prompt-accept', "Use default ({})".format(default))) + + cmds.append(('leave-mode', "Abort")) + return cmds + + +class AlertPrompt(_BasePrompt): + + """A prompt without any answer possibility.""" + + def __init__(self, question, parent=None): + super().__init__(question, parent) + self._init_texts(question) + self._init_key_label() + + def accept(self, value=None): + if value is not None: + raise Error("No value is permitted with alert prompts!") + # Simply mark prompt as done without setting self.question.answer + return True + + def _allowed_commands(self): + return [('prompt-accept', "Hide")] + + +def init(): + """Initialize global prompt objects.""" + global prompt_queue + prompt_queue = PromptQueue() + objreg.register('prompt-queue', prompt_queue) # for commands + message.global_bridge.ask_question.connect( + prompt_queue.ask_question, Qt.DirectConnection) diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 1645ce583..21e0d46ea 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -25,8 +25,7 @@ from PyQt5.QtWidgets import QWidget, QHBoxLayout, QStackedLayout, QSizePolicy from qutebrowser.config import config, style from qutebrowser.utils import usertypes, log, objreg, utils from qutebrowser.mainwindow.statusbar import (command, progress, keystring, - percentage, url, prompt, - tabindex) + percentage, url, tabindex) from qutebrowser.mainwindow.statusbar import text as textwidget @@ -113,8 +112,8 @@ class StatusBar(QWidget): QWidget#StatusBar[prompt_active="true"], QWidget#StatusBar[prompt_active="true"] QLabel, QWidget#StatusBar[prompt_active="true"] QLineEdit { - color: {{ color['statusbar.fg.prompt'] }}; - background-color: {{ color['statusbar.bg.prompt'] }}; + color: {{ color['prompts.fg'] }}; + background-color: {{ color['prompts.bg'] }}; } QWidget#StatusBar[insert_active="true"], @@ -162,16 +161,9 @@ class StatusBar(QWidget): self.txt = textwidget.Text() self._stack.addWidget(self.txt) - self.prompt = prompt.Prompt(win_id) - self._stack.addWidget(self.prompt) - self.cmd.show_cmd.connect(self._show_cmd_widget) self.cmd.hide_cmd.connect(self._hide_cmd_widget) self._hide_cmd_widget() - prompter = objreg.get('prompter', scope='window', window=self._win_id) - prompter.show_prompt.connect(self._show_prompt_widget) - prompter.hide_prompt.connect(self._hide_prompt_widget) - self._hide_prompt_widget() self.keystring = keystring.KeyString() self._hbox.addWidget(self.keystring) @@ -216,16 +208,6 @@ class StatusBar(QWidget): """Getter for self.prompt_active, so it can be used as Qt property.""" return self._prompt_active - def _set_prompt_active(self, val): - """Setter for self.prompt_active. - - Re-set the stylesheet after setting the value, so everything gets - updated by Qt properly. - """ - log.statusbar.debug("Setting prompt_active to {}".format(val)) - self._prompt_active = val - self.setStyleSheet(style.get_stylesheet(self.STYLESHEET)) - @pyqtProperty(bool) def command_active(self): """Getter for self.command_active, so it can be used as Qt property.""" @@ -253,6 +235,9 @@ class StatusBar(QWidget): if mode == usertypes.KeyMode.command: log.statusbar.debug("Setting command_active to {}".format(val)) self._command_active = val + elif mode in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: + log.statusbar.debug("Setting prompt_active to {}".format(val)) + self._prompt_active = val elif mode == usertypes.KeyMode.caret: tab = objreg.get('tabbed-browser', scope='window', window=self._win_id).currentWidget() @@ -285,21 +270,6 @@ class StatusBar(QWidget): self._stack.setCurrentWidget(self.txt) self.maybe_hide() - def _show_prompt_widget(self): - """Show prompt widget instead of temporary text.""" - if self._stack.currentWidget() is self.prompt: - return - self._set_prompt_active(True) - self._stack.setCurrentWidget(self.prompt) - self.show() - - def _hide_prompt_widget(self): - """Show temporary text instead of prompt widget.""" - self._set_prompt_active(False) - log.statusbar.debug("Hiding prompt widget") - self._stack.setCurrentWidget(self.txt) - self.maybe_hide() - @pyqtSlot(str) def set_text(self, val): """Set a normal (persistent) text in the status bar.""" @@ -314,7 +284,9 @@ class StatusBar(QWidget): self._set_mode_text(mode.name) if mode in [usertypes.KeyMode.insert, usertypes.KeyMode.command, - usertypes.KeyMode.caret]: + usertypes.KeyMode.caret, + usertypes.KeyMode.prompt, + usertypes.KeyMode.yesno]: self.set_mode_active(mode, True) @pyqtSlot(usertypes.KeyMode, usertypes.KeyMode) @@ -329,7 +301,9 @@ class StatusBar(QWidget): self.txt.set_text(self.txt.Text.normal, '') if old_mode in [usertypes.KeyMode.insert, usertypes.KeyMode.command, - usertypes.KeyMode.caret]: + usertypes.KeyMode.caret, + usertypes.KeyMode.prompt, + usertypes.KeyMode.yesno]: self.set_mode_active(old_mode, False) def resizeEvent(self, e): diff --git a/qutebrowser/mainwindow/statusbar/command.py b/qutebrowser/mainwindow/statusbar/command.py index 79a5f0305..a5abaa290 100644 --- a/qutebrowser/mainwindow/statusbar/command.py +++ b/qutebrowser/mainwindow/statusbar/command.py @@ -77,7 +77,6 @@ class Command(misc.MinimalLineEditMixin, misc.CommandLineEdit): else: return '' - @pyqtSlot(str) def set_cmd_text(self, text): """Preset the statusbar to some text. diff --git a/qutebrowser/mainwindow/statusbar/progress.py b/qutebrowser/mainwindow/statusbar/progress.py index 0f120762e..17892fe33 100644 --- a/qutebrowser/mainwindow/statusbar/progress.py +++ b/qutebrowser/mainwindow/statusbar/progress.py @@ -36,6 +36,7 @@ class Progress(QProgressBar): border-radius: 0px; border: 2px solid transparent; background-color: transparent; + font: {{ font['statusbar'] }}; } QProgressBar::chunk { diff --git a/qutebrowser/mainwindow/statusbar/prompt.py b/qutebrowser/mainwindow/statusbar/prompt.py deleted file mode 100644 index 2015ac599..000000000 --- a/qutebrowser/mainwindow/statusbar/prompt.py +++ /dev/null @@ -1,84 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2016 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Prompt shown in the statusbar.""" - -import functools - -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QHBoxLayout, QWidget, QLineEdit, QSizePolicy - -from qutebrowser.mainwindow.statusbar import textbase, prompter -from qutebrowser.utils import objreg, utils -from qutebrowser.misc import miscwidgets as misc - - -class PromptLineEdit(misc.MinimalLineEditMixin, QLineEdit): - - """QLineEdit with a minimal stylesheet.""" - - def __init__(self, parent=None): - QLineEdit.__init__(self, parent) - misc.MinimalLineEditMixin.__init__(self) - self.textChanged.connect(self.updateGeometry) - - def sizeHint(self): - """Dynamically calculate the needed size.""" - height = super().sizeHint().height() - text = self.text() - if not text: - text = 'x' - width = self.fontMetrics().width(text) - return QSize(width, height) - - -class Prompt(QWidget): - - """The prompt widget shown in the statusbar. - - Attributes: - txt: The TextBase instance (QLabel) used to display the prompt text. - lineedit: The MinimalLineEdit instance (QLineEdit) used for the input. - _hbox: The QHBoxLayout used to display the text and prompt. - """ - - def __init__(self, win_id, parent=None): - super().__init__(parent) - objreg.register('prompt', self, scope='window', window=win_id) - self._hbox = QHBoxLayout(self) - self._hbox.setContentsMargins(0, 0, 0, 0) - self._hbox.setSpacing(5) - - self.txt = textbase.TextBase() - self._hbox.addWidget(self.txt) - - self.lineedit = PromptLineEdit() - self.lineedit.setSizePolicy(QSizePolicy.MinimumExpanding, - QSizePolicy.Fixed) - self._hbox.addWidget(self.lineedit) - - prompter_obj = prompter.Prompter(win_id) - objreg.register('prompter', prompter_obj, scope='window', - window=win_id) - self.destroyed.connect( - functools.partial(objreg.delete, 'prompter', scope='window', - window=win_id)) - - def __repr__(self): - return utils.get_repr(self) diff --git a/qutebrowser/mainwindow/statusbar/prompter.py b/qutebrowser/mainwindow/statusbar/prompter.py deleted file mode 100644 index c93f5912a..000000000 --- a/qutebrowser/mainwindow/statusbar/prompter.py +++ /dev/null @@ -1,411 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2016 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Manager for questions to be shown in the statusbar.""" - -import sip -import collections - -from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, QObject -from PyQt5.QtWidgets import QLineEdit - -from qutebrowser.keyinput import modeman -from qutebrowser.commands import cmdutils, cmdexc -from qutebrowser.utils import usertypes, log, qtutils, objreg, utils - - -PromptContext = collections.namedtuple('PromptContext', - ['question', 'text', 'input_text', - 'echo_mode', 'input_visible']) -AuthTuple = collections.namedtuple('AuthTuple', ['user', 'password']) - - -class Prompter(QObject): - - """Manager for questions to be shown in the statusbar. - - The way in which multiple questions are handled deserves some explanation. - - If a question is blocking, we *need* to ask it immediately, and can't wait - for previous questions to finish. We could theoretically ask a blocking - question inside of another blocking one, so in ask_question we simply save - the current prompt state on the stack, let the user answer the *most - recent* question, and then restore the previous state. - - With a non-blocking question, things are a bit easier. We simply add it to - self._queue if we're still busy handling another question, since it can be - answered at any time. - - In either case, as soon as we finished handling a question, we call - _pop_later() which schedules a _pop to ask the next question in _queue. We - schedule it rather than doing it immediately because then the order of how - things happen is clear, e.g. on_mode_left can't happen after we already set - up the *new* question. - - Class Attributes: - KEY_MODES: A mapping of PromptModes to KeyModes. - - Attributes: - _shutting_down: Whether we're currently shutting down the prompter and - should ignore future questions to avoid segfaults. - _question: A Question object with the question to be asked to the user. - _loops: A list of local EventLoops to spin in when blocking. - _queue: A deque of waiting questions. - _busy: If we're currently busy with asking a question. - _win_id: The window ID this object is associated with. - - Signals: - show_prompt: Emitted when the prompt widget should be shown. - hide_prompt: Emitted when the prompt widget should be hidden. - """ - - KEY_MODES = { - usertypes.PromptMode.yesno: usertypes.KeyMode.yesno, - usertypes.PromptMode.text: usertypes.KeyMode.prompt, - usertypes.PromptMode.user_pwd: usertypes.KeyMode.prompt, - usertypes.PromptMode.alert: usertypes.KeyMode.prompt, - usertypes.PromptMode.download: usertypes.KeyMode.prompt, - } - - show_prompt = pyqtSignal() - hide_prompt = pyqtSignal() - - def __init__(self, win_id, parent=None): - super().__init__(parent) - self._shutting_down = False - self._question = None - self._loops = [] - self._queue = collections.deque() - self._busy = False - self._win_id = win_id - - def __repr__(self): - return utils.get_repr(self, loops=len(self._loops), - question=self._question, queue=len(self._queue), - busy=self._busy) - - def _pop_later(self): - """Helper to call self._pop as soon as everything else is done.""" - QTimer.singleShot(0, self._pop) - - def _pop(self): - """Pop a question from the queue and ask it, if there are any.""" - log.statusbar.debug("Popping from queue {}".format(self._queue)) - if self._queue: - question = self._queue.popleft() - if not sip.isdeleted(question): - # the question could already be deleted, e.g. by a cancelled - # download. See - # https://github.com/The-Compiler/qutebrowser/issues/415 - self.ask_question(question, blocking=False) - - def _get_ctx(self): - """Get a PromptContext based on the current state.""" - if not self._busy: - return None - prompt = objreg.get('prompt', scope='window', window=self._win_id) - ctx = PromptContext(question=self._question, - text=prompt.txt.text(), - input_text=prompt.lineedit.text(), - echo_mode=prompt.lineedit.echoMode(), - input_visible=prompt.lineedit.isVisible()) - return ctx - - def _restore_ctx(self, ctx): - """Restore state from a PromptContext. - - Args: - ctx: A PromptContext previously saved by _get_ctx, or None. - - Return: True if a context was restored, False otherwise. - """ - log.statusbar.debug("Restoring context {}".format(ctx)) - if ctx is None: - self.hide_prompt.emit() - self._busy = False - return False - self._question = ctx.question - prompt = objreg.get('prompt', scope='window', window=self._win_id) - prompt.txt.setText(ctx.text) - prompt.lineedit.setText(ctx.input_text) - prompt.lineedit.setEchoMode(ctx.echo_mode) - prompt.lineedit.setVisible(ctx.input_visible) - self.show_prompt.emit() - mode = self.KEY_MODES[ctx.question.mode] - ctx.question.aborted.connect( - lambda: modeman.maybe_leave(self._win_id, mode, 'aborted')) - modeman.enter(self._win_id, mode, 'question asked') - return True - - def _display_question_yesno(self, prompt): - """Display a yes/no question.""" - if self._question.default is None: - suffix = "" - elif self._question.default: - suffix = " (yes)" - else: - suffix = " (no)" - prompt.txt.setText(self._question.text + suffix) - prompt.lineedit.hide() - - def _display_question_input(self, prompt): - """Display a question with an input.""" - text = self._question.text - if self._question.mode == usertypes.PromptMode.download: - key_mode = self.KEY_MODES[self._question.mode] - key_config = objreg.get('key-config') - all_bindings = key_config.get_reverse_bindings_for(key_mode.name) - bindings = all_bindings.get('prompt-open-download', []) - if bindings: - text += ' ({} to open)'.format(bindings[0]) - prompt.txt.setText(text) - if self._question.default: - prompt.lineedit.setText(self._question.default) - prompt.lineedit.show() - - def _display_question_alert(self, prompt): - """Display a JS alert 'question'.""" - prompt.txt.setText(self._question.text + ' (ok)') - prompt.lineedit.hide() - - def _display_question(self): - """Display the question saved in self._question.""" - prompt = objreg.get('prompt', scope='window', window=self._win_id) - handlers = { - usertypes.PromptMode.yesno: self._display_question_yesno, - usertypes.PromptMode.text: self._display_question_input, - usertypes.PromptMode.user_pwd: self._display_question_input, - usertypes.PromptMode.download: self._display_question_input, - usertypes.PromptMode.alert: self._display_question_alert, - } - handler = handlers[self._question.mode] - handler(prompt) - log.modes.debug("Question asked, focusing {!r}".format( - prompt.lineedit)) - prompt.lineedit.setFocus() - self.show_prompt.emit() - self._busy = True - - def shutdown(self): - """Cancel all blocking questions. - - Quits and removes all running event loops. - - Return: - True if loops needed to be aborted, - False otherwise. - """ - self._shutting_down = True - if self._loops: - for loop in self._loops: - loop.quit() - loop.deleteLater() - return True - else: - return False - - @pyqtSlot(usertypes.KeyMode) - def on_mode_left(self, mode): - """Clear and reset input when the mode was left.""" - prompt = objreg.get('prompt', scope='window', window=self._win_id) - if mode in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: - prompt.txt.setText('') - prompt.lineedit.clear() - prompt.lineedit.setEchoMode(QLineEdit.Normal) - self.hide_prompt.emit() - self._busy = False - if self._question.answer is None and not self._question.is_aborted: - self._question.cancel() - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.prompt, - usertypes.KeyMode.yesno]) - def prompt_accept(self, value=None): - """Accept the current prompt. - - // - - This executes the next action depending on the question mode, e.g. asks - for the password or leaves the mode. - - Args: - value: If given, uses this value instead of the entered one. - For boolean prompts, "yes"/"no" are accepted as value. - """ - prompt = objreg.get('prompt', scope='window', window=self._win_id) - text = value if value is not None else prompt.lineedit.text() - - if (self._question.mode == usertypes.PromptMode.user_pwd and - self._question.user is None): - # User just entered a username - self._question.user = text - prompt.txt.setText("Password:") - prompt.lineedit.clear() - prompt.lineedit.setEchoMode(QLineEdit.Password) - elif self._question.mode == usertypes.PromptMode.user_pwd: - # User just entered a password - self._question.answer = AuthTuple(self._question.user, text) - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'prompt accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.text: - # User just entered text. - self._question.answer = text - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'prompt accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.download: - # User just entered a path for a download. - target = usertypes.FileDownloadTarget(text) - self._question.answer = target - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'prompt accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.yesno: - # User wants to accept the default of a yes/no question. - if value is None: - self._question.answer = self._question.default - elif value == 'yes': - self._question.answer = True - elif value == 'no': - self._question.answer = False - else: - raise cmdexc.CommandError("Invalid value {} - expected " - "yes/no!".format(value)) - modeman.maybe_leave(self._win_id, usertypes.KeyMode.yesno, - 'yesno accept') - self._question.done() - elif self._question.mode == usertypes.PromptMode.alert: - if value is not None: - raise cmdexc.CommandError("No value is permitted with alert " - "prompts!") - # User acknowledged an alert - self._question.answer = None - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'alert accept') - self._question.done() - else: - raise ValueError("Invalid question mode!") - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.yesno], - deprecated='Use :prompt-accept yes instead!') - def prompt_yes(self): - """Answer yes to a yes/no prompt.""" - if self._question.mode != usertypes.PromptMode.yesno: - # We just ignore this if we don't have a yes/no question. - return - self._question.answer = True - modeman.maybe_leave(self._win_id, usertypes.KeyMode.yesno, - 'yesno accept') - self._question.done() - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.yesno], - deprecated='Use :prompt-accept no instead!') - def prompt_no(self): - """Answer no to a yes/no prompt.""" - if self._question.mode != usertypes.PromptMode.yesno: - # We just ignore this if we don't have a yes/no question. - return - self._question.answer = False - modeman.maybe_leave(self._win_id, usertypes.KeyMode.yesno, - 'prompt accept') - self._question.done() - - @cmdutils.register(instance='prompter', hide=True, scope='window', - modes=[usertypes.KeyMode.prompt], maxsplit=0) - def prompt_open_download(self, cmdline: str=None): - """Immediately open a download. - - If no specific command is given, this will use the system's default - application to open the file. - - Args: - cmdline: The command which should be used to open the file. A `{}` - is expanded to the temporary file name. If no `{}` is - present, the filename is automatically appended to the - cmdline. - """ - if self._question.mode != usertypes.PromptMode.download: - # We just ignore this if we don't have a download question. - return - self._question.answer = usertypes.OpenFileDownloadTarget(cmdline) - modeman.maybe_leave(self._win_id, usertypes.KeyMode.prompt, - 'download open') - self._question.done() - - @pyqtSlot(usertypes.Question, bool) - def ask_question(self, question, blocking): - """Display a question in the statusbar. - - Args: - question: The Question object to ask. - blocking: If True, this function blocks and returns the result. - - Return: - The answer of the user when blocking=True. - None if blocking=False. - """ - log.statusbar.debug("Asking question {}, blocking {}, loops {}, queue " - "{}".format(question, blocking, self._loops, - self._queue)) - - if self._shutting_down: - # If we're currently shutting down we have to ignore this question - # to avoid segfaults - see - # https://github.com/The-Compiler/qutebrowser/issues/95 - log.statusbar.debug("Ignoring question because we're shutting " - "down.") - question.abort() - return None - - if self._busy and not blocking: - # We got an async question, but we're already busy with one, so we - # just queue it up for later. - log.statusbar.debug("Adding {} to queue.".format(question)) - self._queue.append(question) - return - - if blocking: - # If we're blocking we save the old state on the stack, so we can - # restore it after exec, if exec gets called multiple times. - context = self._get_ctx() - - self._question = question - self._display_question() - mode = self.KEY_MODES[self._question.mode] - question.aborted.connect( - lambda: modeman.maybe_leave(self._win_id, mode, 'aborted')) - modeman.enter(self._win_id, mode, 'question asked') - if blocking: - loop = qtutils.EventLoop() - self._loops.append(loop) - loop.destroyed.connect(lambda: self._loops.remove(loop)) - question.completed.connect(loop.quit) - question.completed.connect(loop.deleteLater) - loop.exec_() - if not self._restore_ctx(context): - # Nothing left to restore, so we can go back to popping async - # questions. - if self._queue: - self._pop_later() - return self._question.answer - else: - question.completed.connect(self._pop_later) diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 6efec0851..e1a07146a 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -558,6 +558,11 @@ class TabbedBrowser(tabwidget.TabWidget): # closing the last tab (before quitting) or shutting down return tab = self.widget(idx) + if tab is None: + log.webview.debug("on_current_changed got called with invalid " + "index {}".format(idx)) + return + log.modes.debug("Current tab changed, focusing {!r}".format(tab)) tab.setFocus() for mode in [usertypes.KeyMode.hint, usertypes.KeyMode.insert, diff --git a/qutebrowser/misc/crashdialog.py b/qutebrowser/misc/crashdialog.py index 193865d28..397d2ed9d 100644 --- a/qutebrowser/misc/crashdialog.py +++ b/qutebrowser/misc/crashdialog.py @@ -381,7 +381,7 @@ class _CrashDialog(QDialog): lines = ['The report has been sent successfully. Thanks!'] lines.append("There was an error while getting the newest version: " "{}. Please check for a new version on " - "qutebrowser.org " + "qutebrowser.org " "by yourself.".format(msg)) text = '

    '.join(lines) self.finish() diff --git a/qutebrowser/misc/ipc.py b/qutebrowser/misc/ipc.py index 4f77379d0..6641a9ee8 100644 --- a/qutebrowser/misc/ipc.py +++ b/qutebrowser/misc/ipc.py @@ -362,6 +362,9 @@ class IPCServer(QObject): @pyqtSlot() def on_timeout(self): """Cancel the current connection if it was idle for too long.""" + if self._socket is None: # pragma: no cover + log.ipc.error("on_timeout got called with None socket!") + return log.ipc.error("IPC connection timed out " "(socket 0x{:x}).".format(id(self._socket))) self._socket.disconnectFromServer() diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 667edeb1c..c66712eb9 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -45,6 +45,19 @@ class KeyHintView(QLabel): update_geometry: Emitted when this widget should be resized/positioned. """ + STYLESHEET = """ + QLabel { + font: {{ font['keyhint'] }}; + color: {{ color['keyhint.fg'] }}; + background-color: {{ color['keyhint.bg'] }}; + padding: 6px; + {% if config.get('ui', 'status-position') == 'top' %} + border-bottom-right-radius: 6px; + {% else %} + border-top-right-radius: 6px; + {% endif %} + } + """ update_geometry = pyqtSignal() def __init__(self, win_id, parent=None): @@ -56,8 +69,7 @@ class KeyHintView(QLabel): self._show_timer = usertypes.Timer(self, 'keyhint_show') self._show_timer.setInterval(500) self._show_timer.timeout.connect(self.show) - style.set_register_stylesheet(self, - generator=self._generate_stylesheet) + style.set_register_stylesheet(self) def __repr__(self): return utils.get_repr(self, win_id=self._win_id) @@ -67,22 +79,6 @@ class KeyHintView(QLabel): self.update_geometry.emit() super().showEvent(e) - def _generate_stylesheet(self): - """Generate a stylesheet with the right edge rounded.""" - stylesheet = """ - QLabel { - font: {{ font['keyhint'] }}; - color: {{ color['keyhint.fg'] }}; - background-color: {{ color['keyhint.bg'] }}; - padding: 6px; - border-EDGE-radius: 6px; - } - """ - if config.get('ui', 'status-position') == 'top': - return stylesheet.replace('EDGE', 'bottom-right') - else: - return stylesheet.replace('EDGE', 'top-right') - @pyqtSlot(str) def update_keyhint(self, modename, prefix): """Show hints for the given prefix (or hide if prefix is empty). diff --git a/qutebrowser/misc/lineparser.py b/qutebrowser/misc/lineparser.py index b9300a392..4999c2e22 100644 --- a/qutebrowser/misc/lineparser.py +++ b/qutebrowser/misc/lineparser.py @@ -92,6 +92,12 @@ class BaseLineParser(QObject): Args: mode: The mode to use ('a'/'r'/'w') + + Raises: + IOError: if the file is already open + + Yields: + a file object for the config file """ assert self._configfile is not None if self._opened: diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 62202998c..affcb6f1d 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -20,7 +20,8 @@ """Misc. utility commands exposed to the user.""" import functools -import types +import os +import signal import traceback try: @@ -142,10 +143,7 @@ def debug_crash(typ='exception'): typ: either 'exception' or 'segfault'. """ if typ == 'segfault': - # From python's Lib/test/crashers/bogus_code_obj.py - co = types.CodeType(0, 0, 0, 0, 0, b'\x04\x71\x00\x00', (), (), (), - '', '', 1, b'') - exec(co) + os.kill(os.getpid(), signal.SIGSEGV) raise Exception("Segfault failed (wat.)") else: raise Exception("Forced crash") @@ -173,12 +171,15 @@ def debug_console(): try: con_widget = objreg.get('debug-console') except KeyError: + log.misc.debug('initializing debug console') con_widget = consolewidget.ConsoleWidget() objreg.register('debug-console', con_widget) if con_widget.isVisible(): + log.misc.debug('hiding debug console') con_widget.hide() else: + log.misc.debug('showing debug console') con_widget.show() diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 044b75b65..1a3db19be 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -94,7 +94,7 @@ LOGGER_NAMES = [ 'commands', 'signals', 'downloads', 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', 'save', 'message', 'config', 'sessions', - 'webelem' + 'webelem', 'prompt' ] @@ -139,6 +139,7 @@ message = logging.getLogger('message') config = logging.getLogger('config') sessions = logging.getLogger('sessions') webelem = logging.getLogger('webelem') +prompt = logging.getLogger('prompt') ram_handler = None @@ -406,6 +407,10 @@ def qt_message_handler(msg_type, context, msg): "Chromium-based browser to ", # https://github.com/The-Compiler/qutebrowser/issues/1287 "QXcbClipboard: SelectionRequest too old", + # https://github.com/The-Compiler/qutebrowser/issues/2071 + 'QXcbWindow: Unhandled client message: ""', + # No idea where this comes from... + "QObject::disconnect: Unexpected null parameter", ] if sys.platform == 'darwin': suppressed_msgs += [ diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index 43c820ad1..368bb8289 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -26,7 +26,7 @@ import traceback from PyQt5.QtCore import pyqtSignal, QObject -from qutebrowser.utils import usertypes, log, objreg, utils +from qutebrowser.utils import usertypes, log, utils def _log_stack(typ, stack): @@ -76,70 +76,83 @@ def info(message): global_bridge.show_message.emit(usertypes.MessageLevel.info, message) -def ask(win_id, message, mode, default=None): +def _build_question(title, text=None, *, mode, default=None, abort_on=()): + """Common function for ask/ask_async.""" + if not isinstance(mode, usertypes.PromptMode): + raise TypeError("Mode {} is no PromptMode member!".format(mode)) + question = usertypes.Question() + question.title = title + question.text = text + question.mode = mode + question.default = default + for sig in abort_on: + sig.connect(question.abort) + return question + + +def ask(*args, **kwargs): """Ask a modular question in the statusbar (blocking). Args: - win_id: The ID of the window which is calling this function. message: The message to display to the user. mode: A PromptMode. default: The default value to display. + text: Additional text to show + abort_on: A list of signals which abort the question if emitted. Return: The answer the user gave or None if the prompt was cancelled. """ - q = usertypes.Question() - q.text = message - q.mode = mode - q.default = default - bridge = objreg.get('message-bridge', scope='window', window=win_id) - bridge.ask(q, blocking=True) - q.deleteLater() - return q.answer + question = _build_question(*args, **kwargs) # pylint: disable=missing-kwoa + global_bridge.ask(question, blocking=True) + answer = question.answer + question.deleteLater() + return answer -def ask_async(win_id, message, mode, handler, default=None): +def ask_async(title, mode, handler, **kwargs): """Ask an async question in the statusbar. Args: - win_id: The ID of the window which is calling this function. message: The message to display to the user. mode: A PromptMode. handler: The function to get called with the answer as argument. default: The default value to display. + text: Additional text to show. """ - if not isinstance(mode, usertypes.PromptMode): - raise TypeError("Mode {} is no PromptMode member!".format(mode)) - q = usertypes.Question() - q.text = message - q.mode = mode - q.default = default - q.answered.connect(handler) - q.completed.connect(q.deleteLater) - bridge = objreg.get('message-bridge', scope='window', window=win_id) - bridge.ask(q, blocking=False) + question = _build_question(title, mode=mode, **kwargs) + question.answered.connect(handler) + question.completed.connect(question.deleteLater) + global_bridge.ask(question, blocking=False) -def confirm_async(win_id, message, yes_action, no_action=None, default=None): +def confirm_async(yes_action, no_action=None, cancel_action=None, + *args, **kwargs): """Ask a yes/no question to the user and execute the given actions. Args: - win_id: The ID of the window which is calling this function. message: The message to display to the user. yes_action: Callable to be called when the user answered yes. no_action: Callable to be called when the user answered no. + cancel_action: Callable to be called when the user cancelled the + question. default: True/False to set a default value, or None. + text: Additional text to show. + + Return: + The question object. """ - q = usertypes.Question() - q.text = message - q.mode = usertypes.PromptMode.yesno - q.default = default - q.answered_yes.connect(yes_action) + kwargs['mode'] = usertypes.PromptMode.yesno + question = _build_question(*args, **kwargs) # pylint: disable=missing-kwoa + question.answered_yes.connect(yes_action) if no_action is not None: - q.answered_no.connect(no_action) - q.completed.connect(q.deleteLater) - bridge = objreg.get('message-bridge', scope='window', window=win_id) - bridge.ask(q, blocking=False) + question.answered_no.connect(no_action) + if cancel_action is not None: + question.cancelled.connect(cancel_action) + + question.completed.connect(question.deleteLater) + global_bridge.ask(question, blocking=False) + return question class GlobalMessageBridge(QObject): @@ -150,9 +163,34 @@ class GlobalMessageBridge(QObject): show_message: Show a message arg 0: A MessageLevel member arg 1: The text to show + prompt_done: Emitted when a prompt was answered somewhere. + ask_question: Ask a question to the user. + arg 0: The Question object to ask. + arg 1: Whether to block (True) or ask async (False). + + IMPORTANT: Slots need to be connected to this signal via + a Qt.DirectConnection! + mode_left: Emitted when a keymode was left in any window. """ show_message = pyqtSignal(usertypes.MessageLevel, str) + prompt_done = pyqtSignal(usertypes.KeyMode) + ask_question = pyqtSignal(usertypes.Question, bool) + mode_left = pyqtSignal(usertypes.KeyMode) + + def ask(self, question, blocking, *, log_stack=False): + """Ask a question to the user. + + Note this method doesn't return the answer, it only blocks. The caller + needs to construct a Question object and get the answer. + + Args: + question: A Question object. + blocking: Whether to return immediately or wait until the + question is answered. + log_stack: ignored + """ + self.ask_question.emit(question, blocking) class MessageBridge(QObject): @@ -164,36 +202,14 @@ class MessageBridge(QObject): arg: The text to set. s_maybe_reset_text: Reset the text if it hasn't been changed yet. arg: The expected text. - s_set_cmd_text: Pre-set a text for the commandline prompt. - arg: The text to set. - - s_question: Ask a question to the user in the statusbar. - arg 0: The Question object to ask. - arg 1: Whether to block (True) or ask async (False). - - IMPORTANT: Slots need to be connected to this signal via a - Qt.DirectConnection! """ s_set_text = pyqtSignal(str) s_maybe_reset_text = pyqtSignal(str) - s_set_cmd_text = pyqtSignal(str) - s_question = pyqtSignal(usertypes.Question, bool) def __repr__(self): return utils.get_repr(self) - def set_cmd_text(self, text, *, log_stack=False): - """Set the command text of the statusbar. - - Args: - text: The text to set. - log_stack: ignored - """ - text = str(text) - log.message.debug(text) - self.s_set_cmd_text.emit(text) - def set_text(self, text, *, log_stack=False): """Set the normal text of the statusbar. @@ -214,19 +230,5 @@ class MessageBridge(QObject): """ self.s_maybe_reset_text.emit(str(text)) - def ask(self, question, blocking, *, log_stack=False): - """Ask a question to the user. - - Note this method doesn't return the answer, it only blocks. The caller - needs to construct a Question object and get the answer. - - Args: - question: A Question object. - blocking: Whether to return immediately or wait until the - question is answered. - log_stack: ignored - """ - self.s_question.emit(question, blocking) - global_bridge = GlobalMessageBridge() diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index d7728379e..d05424fc3 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -143,7 +143,12 @@ class ObjectRegistry(collections.UserDict): """Dump all objects as a list of strings.""" lines = [] for name, obj in self.data.items(): - lines.append("{}: {}".format(name, repr(obj))) + try: + obj_repr = repr(obj) + except (RuntimeError, TypeError): + # Underlying object deleted probably + obj_repr = '' + lines.append("{}: {}".format(name, obj_repr)) return lines diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 39d7e4209..120d529f7 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -335,10 +335,11 @@ class Question(QObject): For yesno, None (no default), True or False. For text, a default text as string. For user_pwd, a default username as string. + title: The question title to show. text: The prompt text to display to the user. - user: The value the user entered as username. answer: The value the user entered (as password for user_pwd). is_aborted: Whether the question was aborted. + interrupted: Whether the question was interrupted by another one. Signals: answered: Emitted when the question has been answered by the user. @@ -364,14 +365,15 @@ class Question(QObject): super().__init__(parent) self._mode = None self.default = None + self.title = None self.text = None - self.user = None self.answer = None self.is_aborted = False + self.interrupted = False def __repr__(self): - return utils.get_repr(self, text=self.text, mode=self._mode, - default=self.default) + return utils.get_repr(self, title=self.title, text=self.text, + mode=self._mode, default=self.default) @property def mode(self): @@ -405,6 +407,9 @@ class Question(QObject): @pyqtSlot() def abort(self): """Abort the question.""" + if self.is_aborted: + log.misc.debug("Question was already aborted") + return self.is_aborted = True try: self.aborted.emit() diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 03ef4c7d9..95241e5ac 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -258,7 +258,7 @@ def github_upload(artifacts, tag): with open(filename, 'rb') as f: basename = os.path.basename(filename) asset = release.upload_asset(mimetype, basename, f) - asset.edit(filename, description) + asset.edit(basename, description) def pypi_upload(artifacts): diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index e9bb675b0..698e04b79 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -115,8 +115,6 @@ PERFECT_FILES = [ 'qutebrowser/mainwindow/statusbar/tabindex.py'), ('tests/unit/mainwindow/statusbar/test_textbase.py', 'qutebrowser/mainwindow/statusbar/textbase.py'), - ('tests/unit/mainwindow/statusbar/test_prompt.py', - 'qutebrowser/mainwindow/statusbar/prompt.py'), ('tests/unit/mainwindow/statusbar/test_url.py', 'qutebrowser/mainwindow/statusbar/url.py'), ('tests/unit/mainwindow/test_messageview.py', diff --git a/scripts/dev/ci/appveyor_install.py b/scripts/dev/ci/appveyor_install.py index f27f4914b..2c04304d5 100644 --- a/scripts/dev/ci/appveyor_install.py +++ b/scripts/dev/ci/appveyor_install.py @@ -47,7 +47,7 @@ def pip_install(pkg): print("Getting PyQt5...") qt_version = '5.5.1' pyqt_version = '5.5.1' -pyqt_url = ('http://www.qutebrowser.org/pyqt/' +pyqt_url = ('https://www.qutebrowser.org/pyqt/' 'PyQt5-{}-gpl-Py3.4-Qt{}-x32.exe'.format( pyqt_version, qt_version)) @@ -61,8 +61,8 @@ except (OSError, IOError): print("Installing PyQt5...") subprocess.check_call([r'C:\install-PyQt5.exe', '/S']) -print("Installing pip/tox") -pip_install(r'-rmisc\requirements\requirements-pip.txt') +print("Installing tox") +pip_install('pip') pip_install(r'-rmisc\requirements\requirements-tox.txt') print("Linking Python...") diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh index 4edb587a4..f5d75eed5 100644 --- a/scripts/dev/ci/travis_install.sh +++ b/scripts/dev/ci/travis_install.sh @@ -54,7 +54,7 @@ brew_install() { pip_install() { # this uses python2 - travis_retry sudo -H python -m pip install -r misc/requirements/requirements-$1.txt + travis_retry sudo -H python -m pip install "$@" } npm_install() { @@ -95,7 +95,7 @@ elif [[ $TRAVIS_OS_NAME == osx ]]; then brew --version brew_install python3 qt5 pyqt5 - pip_install tox + pip_install -r misc/requirements/requirements-tox.txt pip --version tox --version check_pyqt @@ -105,14 +105,14 @@ fi pyqt_pkgs="python3-pyqt5 python3-pyqt5.qtwebkit" pip_install pip -pip_install tox +pip_install -r misc/requirements/requirements-tox.txt pip --version tox --version case $TESTENV in py34-cov) - pip_install codecov + pip_install -r misc/requirements/requirements-codecov.txt apt_install xvfb $pyqt_pkgs libpython3.4-dev check_pyqt ;; diff --git a/scripts/dev/get_coredumpctl_traces.py b/scripts/dev/get_coredumpctl_traces.py index 76936e319..49620a96a 100644 --- a/scripts/dev/get_coredumpctl_traces.py +++ b/scripts/dev/get_coredumpctl_traces.py @@ -77,7 +77,11 @@ def get_info(pid): for line in output.split('\n'): if not line.strip(): continue - key, value = line.split(':', maxsplit=1) + try: + key, value = line.split(':', maxsplit=1) + except ValueError: + # systemd stack output + continue data[key.strip()] = value.strip() return data diff --git a/scripts/setupcommon.py b/scripts/setupcommon.py index db078f5ca..9a447d280 100644 --- a/scripts/setupcommon.py +++ b/scripts/setupcommon.py @@ -104,7 +104,7 @@ setupdata = { 'version': '.'.join(str(e) for e in _get_constant('version_info')), 'description': _get_constant('description'), 'long_description': read_file('README.asciidoc'), - 'url': 'http://www.qutebrowser.org/', + 'url': 'https://www.qutebrowser.org/', 'requires': ['pypeg2', 'jinja2', 'pygments', 'PyYAML'], 'author': _get_constant('author'), 'author_email': _get_constant('email'), diff --git a/scripts/testbrowser_cpp/webengine/main.cpp b/scripts/testbrowser_cpp/webengine/main.cpp new file mode 100644 index 000000000..311432e92 --- /dev/null +++ b/scripts/testbrowser_cpp/webengine/main.cpp @@ -0,0 +1,13 @@ +#include +#include +#include + + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QWebEngineView view; + view.load(QUrl(argv[1])); + view.show(); + return app.exec(); +} diff --git a/scripts/testbrowser_cpp/webengine/testbrowser.pro b/scripts/testbrowser_cpp/webengine/testbrowser.pro new file mode 100644 index 000000000..12a1cf7f6 --- /dev/null +++ b/scripts/testbrowser_cpp/webengine/testbrowser.pro @@ -0,0 +1,6 @@ +QT += core widgets webenginewidgets + +TARGET = testbrowser +TEMPLATE = app + +SOURCES += main.cpp diff --git a/scripts/testbrowser_cpp/main.cpp b/scripts/testbrowser_cpp/webkit/main.cpp similarity index 100% rename from scripts/testbrowser_cpp/main.cpp rename to scripts/testbrowser_cpp/webkit/main.cpp diff --git a/scripts/testbrowser_cpp/testbrowser.pro b/scripts/testbrowser_cpp/webkit/testbrowser.pro similarity index 100% rename from scripts/testbrowser_cpp/testbrowser.pro rename to scripts/testbrowser_cpp/webkit/testbrowser.pro diff --git a/tests/conftest.py b/tests/conftest.py index 71018fd1d..907656b80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,7 +121,7 @@ def pytest_collection_modifyitems(config, items): if item.get_marker('xfail_norun'): item.add_marker(pytest.mark.xfail(run=False)) if item.get_marker('flaky_once'): - item.add_marker(pytest.mark.flaky(reruns=1)) + item.add_marker(pytest.mark.flaky()) if deselected: deselected_items.append(item) diff --git a/tests/end2end/data/prompt/jsprompt.html b/tests/end2end/data/prompt/jsprompt.html index d8c848553..4279fc075 100644 --- a/tests/end2end/data/prompt/jsprompt.html +++ b/tests/end2end/data/prompt/jsprompt.html @@ -3,13 +3,14 @@ - + + diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index 3eb4daf0d..9b7a9e2d5 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -188,19 +188,21 @@ def open_path(quteproc, path): do_not_wait_suffix = ' without waiting' as_url_suffix = ' as a URL' - if path.endswith(new_tab_suffix): - path = path[:-len(new_tab_suffix)] - new_tab = True - elif path.endswith(new_window_suffix): - path = path[:-len(new_window_suffix)] - new_window = True - elif path.endswith(as_url_suffix): - path = path[:-len(as_url_suffix)] - as_url = True - - if path.endswith(do_not_wait_suffix): - path = path[:-len(do_not_wait_suffix)] - wait = False + while True: + if path.endswith(new_tab_suffix): + path = path[:-len(new_tab_suffix)] + new_tab = True + elif path.endswith(new_window_suffix): + path = path[:-len(new_window_suffix)] + new_window = True + elif path.endswith(as_url_suffix): + path = path[:-len(as_url_suffix)] + as_url = True + elif path.endswith(do_not_wait_suffix): + path = path[:-len(do_not_wait_suffix)] + wait = False + else: + break quteproc.open_path(path, new_tab=new_tab, new_window=new_window, as_url=as_url, wait=wait) diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index 18a65dfbc..9a868cd57 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -63,7 +63,7 @@ Feature: Downloading things from a website. And I set storage -> prompt-download-directory to true And I open data/downloads/issue1243.html And I hint with args "links download" and follow a - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log Then the error "Download error: No handler found for qute://!" should be shown Scenario: Downloading a data: link (issue 1214) @@ -71,7 +71,7 @@ Feature: Downloading things from a website. And I set storage -> prompt-download-directory to true And I open data/downloads/issue1214.html And I hint with args "links download" and follow a - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log And I run :leave-mode Then no crash should happen @@ -338,7 +338,7 @@ Feature: Downloading things from a website. When I set storage -> prompt-download-directory to true And I open data/downloads/issue1725.html And I run :click-element id long-link - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log And I directly open the download And I wait until the download is finished Then "Opening * with [*python*]" should be logged @@ -484,3 +484,15 @@ Feature: Downloading things from a website. And I run :click-element id download And I wait until the download is finished Then the downloaded file test.pdf should exist + + Scenario: Answering a question for a cancelled download (#415) + When I set storage -> prompt-download-directory to true + And I run :download http://localhost:(port)/data/downloads/download.bin + And I wait for "Asking question text=* title='Save file to:'>, *" in the log + And I run :download http://localhost:(port)/data/downloads/download2.bin + And I wait for "Asking question text=* title='Save file to:'>, *" in the log + And I run :download-cancel with count 2 + And I run :prompt-accept + And I wait until the download is finished + Then the downloaded file download.bin should exist + And the downloaded file download2.bin should not exist diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index d0622b9c0..0f44adff4 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -180,6 +180,11 @@ Feature: Using hints - data/hints/iframe_target.html - data/hello.txt (active) + Scenario: Clicking on iframe with :hint all current + When I open data/hints/iframe.html + And I hint with args "all current" and follow a + Then no crash should happen + ### hints -> auto-follow-timeout @not_osx diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index a86d88e20..dda097f00 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -71,3 +71,8 @@ Feature: Javascript stuff And I run :tab-only And I run :jseval if (window.open('about:blank')) { console.log('window opened'); } else { console.log('error while opening window'); } Then the javascript message "error while opening window" should be logged + + Scenario: Executing jseval when javascript is disabled + When I set content -> allow-javascript to false + And I run :jseval console.log('jseval executed') + Then the javascript message "jseval executed" should be logged diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index e66cfc94b..c193a1379 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -45,20 +45,6 @@ Feature: Various utility commands. When I run :set-cmd-text foo Then the error "Invalid command text 'foo'." should be shown - ## :message-* - - Scenario: :message-error - When I run :message-error "Hello World" - Then the error "Hello World" should be shown - - Scenario: :message-info - When I run :message-info "Hello World" - Then the message "Hello World" should be shown - - Scenario: :message-warning - When I run :message-warning "Hello World" - Then the warning "Hello World" should be shown - ## :jseval Scenario: :jseval @@ -243,16 +229,6 @@ Feature: Various utility commands. And I run :view-source Then the error "Already viewing source!" should be shown - # :debug-console - - @no_xvfb - Scenario: :debug-console smoke test - When I run :debug-console - And I wait for "Focus object changed: " in the log - And I run :debug-console - And I wait for "Focus object changed: *" in the log - Then no crash should happen - # :help Scenario: :help without topic @@ -348,7 +324,7 @@ Feature: Various utility commands. And I open data/misc/test.pdf And I wait for "[qute://pdfjs/*] PDF * (PDF.js: *)" in the log And I run :jseval document.getElementById("download").click() - And I wait for "Asking question text='Save file to:'>, *" in the log + And I wait for "Asking question text=* title='Save file to:'>, *" in the log And I run :leave-mode Then no crash should happen @@ -496,31 +472,6 @@ Feature: Various utility commands. Then qute://log?level=error should be loaded And the page should contain the plaintext "No messages to show." - Scenario: Using :debug-log-capacity - When I run :debug-log-capacity 100 - And I run :message-info oldstuff - And I run :repeat 20 message-info otherstuff - And I run :message-info newstuff - And I open qute:log - Then the page should contain the plaintext "newstuff" - And the page should not contain the plaintext "oldstuff" - - Scenario: Using :debug-log-capacity with negative capacity - When I run :debug-log-capacity -1 - Then the error "Can't set a negative log capacity!" should be shown - - # :debug-log-level / :debug-log-filter - # Other :debug-log-{level,filter} features are tested in - # unit/utils/test_log.py as using them would break end2end tests. - - Scenario: Using debug-log-level with invalid level - When I run :debug-log-level hello - Then the error "level: Invalid value hello - expected one of: vdebug, debug, info, warning, error, critical" should be shown - - Scenario: Using debug-log-filter with invalid filter - When I run :debug-log-filter blah - Then the error "filters: Invalid value blah - expected one of: statusbar, *" should be shown - ## https://github.com/The-Compiler/qutebrowser/issues/1523 Scenario: Completing a single option argument @@ -561,51 +512,6 @@ Feature: Various utility commands. And I set general -> private-browsing to false Then the page should contain the plaintext "Local storage status: not working" - Scenario: :repeat-command - Given I open data/scroll/simple.html - And I run :tab-only - When I run :scroll down - And I run :repeat-command - And I run :scroll up - Then the page should be scrolled vertically - - Scenario: :repeat-command with count - Given I open data/scroll/simple.html - And I run :tab-only - When I run :scroll down with count 3 - And I wait until the scroll position changed - And I run :scroll up - And I wait until the scroll position changed - And I run :repeat-command with count 2 - And I wait until the scroll position changed to 0/0 - Then the page should not be scrolled - - Scenario: :repeat-command with not-normal command inbetween - Given I open data/scroll/simple.html - And I run :tab-only - When I run :scroll down with count 3 - And I wait until the scroll position changed - And I run :scroll up - And I wait until the scroll position changed - And I run :prompt-accept - And I run :repeat-command with count 2 - And I wait until the scroll position changed to 0/0 - Then the page should not be scrolled - And the error "prompt-accept: This command is only allowed in prompt/yesno mode, not normal." should be shown - - @qtwebengine_createWindow - Scenario: :repeat-command with mode-switching command - Given I open data/hints/link_blank.html - And I run :tab-only - When I hint with args "all" - And I run :leave-mode - And I run :repeat-command - And I run :follow-hint a - And I wait until data/hello.txt is loaded - Then the following tabs should be open: - - data/hints/link_blank.html - - data/hello.txt (active) - Scenario: Using 0 as count When I run :scroll down with count 0 Then the error "scroll: A zero count is not allowed for this command!" should be shown @@ -762,13 +668,3 @@ Feature: Various utility commands. And I run :command-accept And I set general -> private-browsing to false Then the message "blah" should be shown - - ## :run-with-count - - Scenario: :run-with-count - When I run :run-with-count 2 scroll down - Then "command called: scroll ['down'] (count=2)" should be logged - - Scenario: :run-with-count with count - When I run :run-with-count 2 scroll down with count 3 - Then "command called: scroll ['down'] (count=6)" should be logged diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature index 05324061f..fe39a90be 100644 --- a/tests/end2end/features/prompts.feature +++ b/tests/end2end/features/prompts.feature @@ -49,6 +49,14 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: prompt test" should be logged + @pyqt>=5.3.1 + Scenario: Javascript prompt with default + When I open data/prompt/jsprompt.html + And I run :click-element id button-default + And I wait for a prompt + And I run :prompt-accept + Then the javascript message "Prompt reply: default" should be logged + @pyqt>=5.3.1 Scenario: Rejected javascript prompt When I open data/prompt/jsprompt.html @@ -58,6 +66,68 @@ Feature: Prompts And I run :leave-mode Then the javascript message "Prompt reply: null" should be logged + # Multiple prompts + + Scenario: Blocking question interrupted by blocking one + When I set content -> ignore-javascript-alert to false + And I open data/prompt/jsalert.html + And I run :click-element id button + And I wait for a prompt + And I open data/prompt/jsconfirm.html in a new tab + And I run :click-element id button + And I wait for a prompt + # JS confirm + And I run :prompt-accept yes + # JS alert + And I run :prompt-accept + Then the javascript message "confirm reply: true" should be logged + And the javascript message "Alert done" should be logged + + Scenario: Blocking question interrupted by async one + When I set content -> ignore-javascript-alert to false + And I set content -> notifications to ask + And I open data/prompt/jsalert.html + And I run :click-element id button + And I wait for a prompt + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + # JS alert + And I run :prompt-accept + # notification permission + And I run :prompt-accept yes + Then the javascript message "Alert done" should be logged + And the javascript message "notification permission granted" should be logged + + Scenario: Async question interrupted by async one + When I set content -> notifications to ask + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + And I run :quickmark-save + And I wait for a prompt + # notification permission + And I run :prompt-accept yes + # quickmark + And I run :prompt-accept test + Then the javascript message "notification permission granted" should be logged + And "Added quickmark test for *" should be logged + + Scenario: Async question interrupted by blocking one + When I set content -> notifications to ask + And I set content -> ignore-javascript-alert to false + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + And I open data/prompt/jsalert.html in a new tab + And I run :click-element id button + And I wait for a prompt + # JS alert + And I run :prompt-accept + # notification permission + And I run :prompt-accept yes + Then the javascript message "Alert done" should be logged + And the javascript message "notification permission granted" should be logged # Shift-Insert with prompt (issue 1299) @@ -72,6 +142,17 @@ Feature: Prompts And I run :prompt-accept Then the javascript message "Prompt reply: insert test" should be logged + @pyqt>=5.3.1 + Scenario: Pasting via shift-insert without it being supported + When selection is not supported + And I put "insert test" into the primary selection + And I open data/prompt/jsprompt.html + And I run :click-element id button + And I wait for a prompt + And I press the keys "" + And I run :prompt-accept + Then the javascript message "Prompt reply: " should be logged + @pyqt>=5.3.1 Scenario: Using content -> ignore-javascript-prompt When I set content -> ignore-javascript-prompt to true @@ -219,6 +300,44 @@ Feature: Prompts "user": "user" } + Scenario: Authentication with :prompt-accept value + When I open about:blank in a new tab + And I open basic-auth/user/password without waiting + And I wait for a prompt + And I run :prompt-accept user:password + And I wait until basic-auth/user/password is loaded + Then the json on the page should be: + { + "authenticated": true, + "user": "user" + } + + Scenario: Authentication with invalid :prompt-accept value + When I open about:blank in a new tab + And I open basic-auth/user/password without waiting + And I wait for a prompt + And I run :prompt-accept foo + And I run :prompt-accept user:password + Then the error "Value needs to be in the format username:password, but foo was given" should be shown + + Scenario: Tabbing between username and password + When I open about:blank in a new tab + And I open basic-auth/user/password without waiting + And I wait for a prompt + And I press the keys "us" + And I run :prompt-item-focus next + And I press the keys "password" + And I run :prompt-item-focus prev + And I press the keys "er" + And I run :prompt-accept + And I run :prompt-accept + And I wait until basic-auth/user/password is loaded + Then the json on the page should be: + { + "authenticated": true, + "user": "user" + } + # :prompt-accept with value argument Scenario: Javascript alert with value @@ -249,3 +368,102 @@ Feature: Prompts And I run :prompt-accept yes Then the javascript message "confirm reply: true" should be logged And the error "Invalid value nope - expected yes/no!" should be shown + + Scenario: Javascript confirm with default value + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :prompt-accept + And I run :prompt-accept yes + Then the javascript message "confirm reply: true" should be logged + And the error "No default value was set for this question!" should be shown + + Scenario: Javascript confirm with deprecated :prompt-yes command + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :prompt-yes + Then the javascript message "confirm reply: true" should be logged + And the warning "prompt-yes is deprecated - Use :prompt-accept yes instead!" should be shown + + Scenario: Javascript confirm with deprecated :prompt-no command + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :prompt-no + Then the javascript message "confirm reply: false" should be logged + And the warning "prompt-no is deprecated - Use :prompt-accept no instead!" should be shown + + # Other + + Scenario: Shutting down with a question + When I open data/prompt/jsconfirm.html + And I run :click-element id button + And I wait for a prompt + And I run :quit + Then the javascript message "confirm reply: false" should be logged + And qutebrowser should quit + + Scenario: Using :prompt-open-download with a prompt which does not support it + When I open data/hello.txt + And I run :quickmark-save + And I wait for a prompt + And I run :prompt-open-download + And I run :prompt-accept test-prompt-open-download + Then "Added quickmark test-prompt-open-download for *" should be logged + + Scenario: Using :prompt-item-focus with a prompt which does not support it + When I open data/hello.txt + And I run :quickmark-save + And I wait for a prompt + And I run :prompt-item-focus next + And I run :prompt-accept test-prompt-item-focus + Then "Added quickmark test-prompt-item-focus for *" should be logged + + Scenario: Getting question in command mode + When I open data/hello.txt + And I run :later 500 quickmark-save + And I run :set-cmd-text : + And I wait for a prompt + And I run :prompt-accept prompt-in-command-mode + Then "Added quickmark prompt-in-command-mode for *" should be logged + + # https://github.com/The-Compiler/qutebrowser/issues/1093 + Scenario: Keyboard focus with multiple auth prompts + When I open basic-auth/user1/password1 without waiting + And I open basic-auth/user2/password2 in a new tab without waiting + And I wait for a prompt + And I wait for a prompt + # Second prompt (showed first) + And I press the keys "user2" + And I press the key "" + And I press the keys "password2" + And I press the key "" + And I wait until basic-auth/user2/password2 is loaded + # First prompt + And I press the keys "user1" + And I press the key "" + And I press the keys "password1" + And I press the key "" + And I wait until basic-auth/user1/password1 is loaded + # We're on the second page + Then the json on the page should be: + { + "authenticated": true, + "user": "user2" + } + + # https://github.com/The-Compiler/qutebrowser/issues/1249#issuecomment-175205531 + # https://github.com/The-Compiler/qutebrowser/pull/2054#issuecomment-258285544 + Scenario: Interrupting SSL prompt during a notification prompt + When I set content -> notifications to ask + And I set network -> ssl-strict to ask + And I open data/prompt/notifications.html in a new tab + And I run :click-element id button + And I wait for a prompt + And I open about:blank in a new tab + And I load an SSL page + And I wait for a prompt + And I run :tab-close + And I run :prompt-accept yes + Then the javascript message "notification permission granted" should be logged diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py index 3ce6790bb..ac82aa04f 100644 --- a/tests/end2end/features/test_downloads_bdd.py +++ b/tests/end2end/features/test_downloads_bdd.py @@ -31,8 +31,8 @@ pytestmark = pytest.mark.qtwebengine_todo("Downloads not implemented yet", PROMPT_MSG = ("Asking question " - "text='Save file to:'>, *") + "default={!r} mode= text=* " + "title='Save file to:'>, *") @bdd.given("I set up a temporary download dir") diff --git a/tests/end2end/features/test_prompts_bdd.py b/tests/end2end/features/test_prompts_bdd.py index d98acab9a..8a66880b7 100644 --- a/tests/end2end/features/test_prompts_bdd.py +++ b/tests/end2end/features/test_prompts_bdd.py @@ -39,8 +39,7 @@ def wait_ssl_page_finished_loading(quteproc, ssl_server): @bdd.when("I wait for a prompt") def wait_for_prompt(quteproc): - quteproc.wait_for(message='Entering mode KeyMode.* (reason: question ' - 'asked)') + quteproc.wait_for(message='Asking question *') @bdd.then("no prompt should be shown") diff --git a/tests/end2end/features/test_utilcmds_bdd.py b/tests/end2end/features/test_utilcmds_bdd.py new file mode 100644 index 000000000..f90d587f6 --- /dev/null +++ b/tests/end2end/features/test_utilcmds_bdd.py @@ -0,0 +1,22 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +import pytest_bdd as bdd + +bdd.scenarios('utilcmds.feature') diff --git a/tests/end2end/features/utilcmds.feature b/tests/end2end/features/utilcmds.feature new file mode 100644 index 000000000..5f58ced3c --- /dev/null +++ b/tests/end2end/features/utilcmds.feature @@ -0,0 +1,165 @@ +Feature: Miscellaneous utility commands exposed to the user. + + Background: + Given I open data/scroll/simple.html + And I run :tab-only + + ## :later + + Scenario: :later before + When I run :later 500 scroll down + Then the page should not be scrolled + # wait for scroll to execture so we don't ruin our future + And the page should be scrolled vertically + + Scenario: :later after + When I run :later 500 scroll down + And I wait 0.6s + Then the page should be scrolled vertically + + # for some reason, argparser gives us the error instead, see #2046 + @xfail + Scenario: :later with negative delay + When I run :later -1 scroll down + Then the error "I can't run something in the past!" should be shown + + Scenario: :later with humongous delay + When I run :later 36893488147419103232 scroll down + Then the error "Numeric argument is too large for internal int representation." should be shown + + ## :repeat + + Scenario: :repeat simple + When I run :repeat 5 scroll-px 10 0 + And I wait until the scroll position changed to 50/0 + # Then already covered by above And + + Scenario: :repeat zero times + When I run :repeat 0 scroll-px 10 0 + And I wait 0.01s + Then the page should not be scrolled + + ## :run-with-count + + Scenario: :run-with-count + When I run :run-with-count 2 scroll down + Then "command called: scroll ['down'] (count=2)" should be logged + + Scenario: :run-with-count with count + When I run :run-with-count 2 scroll down with count 3 + Then "command called: scroll ['down'] (count=6)" should be logged + + ## :message-* + + Scenario: :message-error + When I run :message-error "Hello World" + Then the error "Hello World" should be shown + + Scenario: :message-info + When I run :message-info "Hello World" + Then the message "Hello World" should be shown + + Scenario: :message-warning + When I run :message-warning "Hello World" + Then the warning "Hello World" should be shown + + # argparser again + @xfail + Scenario: :repeat negative times + When I run :repeat -4 scroll-px 10 0 + Then the error "A negative count doesn't make sense." should be shown + And the page should not be scrolled + + ## :debug-all-objects + + Scenario: :debug-all-objects + When I run :debug-all-objects + Then "*Qt widgets - *Qt objects - *" should be logged + + ## :debug-cache-stats + + Scenario: :debug-cache-stats + When I run :debug-cache-stats + Then "config: CacheInfo(*)" should be logged + And "style: CacheInfo(*)" should be logged + + ## :debug-console + + @no_xvfb + Scenario: :debug-console smoke test + When I run :debug-console + And I wait for "Focus object changed: " in the log + And I run :debug-console + And I wait for "Focus object changed: *" in the log + Then "initializing debug console" should be logged + And "showing debug console" should be logged + And "hiding debug console" should be logged + And no crash should happen + + ## :repeat-command + + Scenario: :repeat-command + When I run :scroll down + And I run :repeat-command + And I run :scroll up + Then the page should be scrolled vertically + + Scenario: :repeat-command with count + When I run :scroll down with count 3 + And I wait until the scroll position changed + And I run :scroll up + And I wait until the scroll position changed + And I run :repeat-command with count 2 + And I wait until the scroll position changed to 0/0 + Then the page should not be scrolled + + Scenario: :repeat-command with not-normal command inbetween + When I run :scroll down with count 3 + And I wait until the scroll position changed + And I run :scroll up + And I wait until the scroll position changed + And I run :prompt-accept + And I run :repeat-command with count 2 + And I wait until the scroll position changed to 0/0 + Then the page should not be scrolled + And the error "prompt-accept: This command is only allowed in prompt/yesno mode, not normal." should be shown + + @qtwebengine_createWindow + Scenario: :repeat-command with mode-switching command + When I open data/hints/link_blank.html + And I run :tab-only + And I hint with args "all" + And I run :leave-mode + And I run :repeat-command + And I run :follow-hint a + And I wait until data/hello.txt is loaded + Then the following tabs should be open: + - data/hints/link_blank.html + - data/hello.txt (active) + + ## :debug-log-capacity + + Scenario: Using :debug-log-capacity + When I run :debug-log-capacity 100 + And I run :message-info oldstuff + And I run :repeat 20 message-info otherstuff + And I run :message-info newstuff + And I open qute:log + Then the page should contain the plaintext "newstuff" + And the page should not contain the plaintext "oldstuff" + + Scenario: Using :debug-log-capacity with negative capacity + When I run :debug-log-capacity -1 + Then the error "Can't set a negative log capacity!" should be shown + + ## :debug-log-level / :debug-log-filter + # Other :debug-log-{level,filter} features are tested in + # unit/utils/test_log.py as using them would break end2end tests. + + Scenario: Using debug-log-level with invalid level + When I run :debug-log-level hello + Then the error "level: Invalid value hello - expected one of: vdebug, debug, info, warning, error, critical" should be shown + + Scenario: Using debug-log-filter with invalid filter + When I run :debug-log-filter blah + Then the error "filters: Invalid value blah - expected one of: statusbar, *" should be shown diff --git a/tests/end2end/features/yankpaste.feature b/tests/end2end/features/yankpaste.feature index bc9bc7ab6..b568f23fe 100644 --- a/tests/end2end/features/yankpaste.feature +++ b/tests/end2end/features/yankpaste.feature @@ -256,6 +256,19 @@ Feature: Yanking and pasting. # Compare Then the javascript message "textarea contents: Hello world" should be logged + Scenario: Inserting text into an empty text field with javascript disabled + When I set general -> log-javascript-console to info + And I set content -> allow-javascript to false + And I open data/paste_primary.html + And I run :click-element id qute-textarea + And I wait for "Clicked editable element!" in the log + And I run :insert-text Hello world + And I run :jseval console.log("textarea contents: " + document.getElementById('qute-textarea').value); + # Enable javascript again for the other tests + And I set content -> allow-javascript to true + # Compare + Then the javascript message "textarea contents: Hello world" should be logged + Scenario: Inserting text into a text field at specific position When I set general -> log-javascript-console to info And I open data/paste_primary.html diff --git a/tests/end2end/fixtures/test_testprocess.py b/tests/end2end/fixtures/test_testprocess.py index 91b45cfce..ef5b445ca 100644 --- a/tests/end2end/fixtures/test_testprocess.py +++ b/tests/end2end/fixtures/test_testprocess.py @@ -152,7 +152,7 @@ def test_process_never_started(qtbot, quit_pyproc): def test_wait_signal_raising(qtbot): """testprocess._wait_signal should raise by default.""" proc = testprocess.Process() - with pytest.raises(qtbot.SignalTimeoutError): + with pytest.raises(qtbot.TimeoutError): with proc._wait_signal(proc.proc.started, timeout=0): pass diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 313884118..2c5d23e65 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -67,12 +67,12 @@ class Request(testprocess.Line): '/favicon.ico': [http.client.NOT_FOUND], '/does-not-exist': [http.client.NOT_FOUND], '/does-not-exist-2': [http.client.NOT_FOUND], - '/basic-auth/user/password': - [http.client.UNAUTHORIZED, http.client.OK], + '/status/404': [http.client.NOT_FOUND], + '/custom/redirect-later': [http.client.FOUND], '/custom/redirect-self': [http.client.FOUND], '/redirect-to': [http.client.FOUND], - '/status/404': [http.client.NOT_FOUND], + '/cookies/set': [http.client.FOUND], } for i in range(15): @@ -81,6 +81,10 @@ class Request(testprocess.Line): http.client.FOUND] path_to_statuses['/absolute-redirect/{}'.format(i)] = [ http.client.FOUND] + for suffix in ['', '1', '2']: + key = '/basic-auth/user{}/password{}'.format(suffix, suffix) + path_to_statuses[key] = [http.client.UNAUTHORIZED, http.client.OK] + default_statuses = [http.client.OK, http.client.NOT_MODIFIED] sanitized = QUrl('http://localhost' + self.path).path() # Remove ?foo diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index b6ccb6e45..b1a6966e8 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -131,8 +131,8 @@ def fake_statusbar(qtbot): statusbar.container = container vbox.addWidget(statusbar) - container.show() - qtbot.waitForWindowShown(container) + with qtbot.waitExposed(container): + container.show() return statusbar diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index c8cf820cd..faafefa82 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -157,7 +157,7 @@ def pattern_match(*, pattern, value): True on a match, False otherwise. """ re_pattern = '.*'.join(re.escape(part) for part in pattern.split('*')) - return re.fullmatch(re_pattern, value) is not None + return re.fullmatch(re_pattern, value, flags=re.DOTALL) is not None def abs_datapath(): diff --git a/tests/manual/js/jsprompt.html b/tests/manual/js/jsprompt.html index 782dc1622..b767fdd2f 100644 --- a/tests/manual/js/jsprompt.html +++ b/tests/manual/js/jsprompt.html @@ -1,8 +1,8 @@ diff --git a/tests/unit/browser/test_tab.py b/tests/unit/browser/test_tab.py index 9a8308c9e..ef35dfafa 100644 --- a/tests/unit/browser/test_tab.py +++ b/tests/unit/browser/test_tab.py @@ -121,5 +121,5 @@ def test_tab(qtbot, view, config_stub, tab_registry, mode_manager): assert tab_w.history._history is view.history() assert view.parent() is tab_w - tab_w.show() - qtbot.waitForWindowShown(tab_w) + with qtbot.waitExposed(tab_w): + tab_w.show() diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 37b5c9676..1afb95764 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -396,7 +396,7 @@ class TestDefaultConfig: If it did change, place a new qutebrowser-vx.y.z.conf in old_configs and then increment the version. """ - assert qutebrowser.__version__ == '0.8.1' + assert qutebrowser.__version__ == '0.8.4' @pytest.mark.parametrize('filename', os.listdir(os.path.join(os.path.dirname(__file__), 'old_configs')), diff --git a/tests/unit/config/test_style.py b/tests/unit/config/test_style.py index bcb8a6b28..6b55aebae 100644 --- a/tests/unit/config/test_style.py +++ b/tests/unit/config/test_style.py @@ -59,24 +59,6 @@ class Obj(QObject): self.rendered_stylesheet = stylesheet -class GeneratedObj(QObject): - - def __init__(self, parent=None): - super().__init__(parent) - self.rendered_stylesheet = None - self._generated = False - - def setStyleSheet(self, stylesheet): - self.rendered_stylesheet = stylesheet - - def generate(self): - if not self._generated: - self._generated = True - return 'one' - else: - return 'two' - - @pytest.mark.parametrize('delete', [True, False]) def test_set_register_stylesheet(delete, qtbot, config_stub, caplog): config_stub.data = {'fonts': {'foo': 'bar'}, 'colors': {}} @@ -105,15 +87,6 @@ def test_set_register_stylesheet(delete, qtbot, config_stub, caplog): assert obj.rendered_stylesheet == expected -def test_set_register_stylesheet_generator(qtbot, config_stub): - config_stub.data = {'fonts': {}, 'colors': {}} - obj = GeneratedObj() - style.set_register_stylesheet(obj, generator=obj.generate) - assert obj.rendered_stylesheet == 'one' - config_stub.changed.emit('foo', 'bar') - assert obj.rendered_stylesheet == 'two' - - class TestColorDict: @pytest.mark.parametrize('key, expected', [ diff --git a/tests/unit/mainwindow/statusbar/test_prompt.py b/tests/unit/mainwindow/statusbar/test_prompt.py deleted file mode 100644 index 86c6122b1..000000000 --- a/tests/unit/mainwindow/statusbar/test_prompt.py +++ /dev/null @@ -1,57 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2016 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -"""Test Prompt widget.""" - -import sip - -import pytest - -from qutebrowser.mainwindow.statusbar.prompt import Prompt -from qutebrowser.utils import objreg - - -@pytest.fixture -def prompt(qtbot, win_registry): - prompt = Prompt(0) - qtbot.addWidget(prompt) - - yield prompt - - # If we don't clean up here, this test will remove 'prompter' from the - # objreg at some point in the future, which will cause some other test to - # fail. - sip.delete(prompt) - - -def test_prompt(prompt): - prompt.show() - objreg.get('prompt', scope='window', window=0) - objreg.get('prompter', scope='window', window=0) - - -@pytest.mark.xfail(reason="This test is broken and I don't get why") -def test_resizing(fake_statusbar, prompt): - fake_statusbar.hbox.addWidget(prompt) - - prompt.txt.setText("Blah?") - old_width = prompt.lineedit.width() - - prompt.lineedit.setText("Hello World" * 100) - assert prompt.lineedit.width() > old_width diff --git a/tests/unit/mainwindow/statusbar/test_textbase.py b/tests/unit/mainwindow/statusbar/test_textbase.py index 8a1b2db39..1fb661296 100644 --- a/tests/unit/mainwindow/statusbar/test_textbase.py +++ b/tests/unit/mainwindow/statusbar/test_textbase.py @@ -51,7 +51,6 @@ def test_elided_text(fake_statusbar, qtbot, elidemode, check): long_string = 'Hello world! ' * 100 label.setText(long_string) label.show() - qtbot.waitForWindowShown(label) assert check(label._elided_text) @@ -74,8 +73,8 @@ def test_resize(qtbot): long_string = 'Hello world! ' * 20 label.setText(long_string) - label.show() - qtbot.waitForWindowShown(label) + with qtbot.waitExposed(label): + label.show() text_1 = label._elided_text label.resize(20, 50) diff --git a/tests/unit/mainwindow/test_messageview.py b/tests/unit/mainwindow/test_messageview.py index 191d14eeb..310ea7180 100644 --- a/tests/unit/mainwindow/test_messageview.py +++ b/tests/unit/mainwindow/test_messageview.py @@ -55,8 +55,8 @@ def view(qtbot, config_stub): usertypes.MessageLevel.warning, usertypes.MessageLevel.error]) def test_single_message(qtbot, view, level): - view.show_message(level, 'test') - qtbot.waitForWindowShown(view) + with qtbot.waitExposed(view): + view.show_message(level, 'test') assert view._messages[0].isVisible() diff --git a/tests/unit/mainwindow/test_prompt.py b/tests/unit/mainwindow/test_prompt.py new file mode 100644 index 000000000..1288ccd3f --- /dev/null +++ b/tests/unit/mainwindow/test_prompt.py @@ -0,0 +1,93 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +import os + +import pytest +from PyQt5.QtCore import Qt + +from qutebrowser.mainwindow import prompt as promptmod +from qutebrowser.utils import usertypes + + +@pytest.fixture(autouse=True) +def setup(qapp, key_config_stub): + key_config_stub.set_bindings_for('prompt', {}) + + +class TestFileCompletion: + + @pytest.fixture + def get_prompt(self, qtbot): + """Get a function to display a prompt with a path.""" + def _get_prompt_func(path): + question = usertypes.Question() + question.title = "test" + question.default = path + + prompt = promptmod.DownloadFilenamePrompt(question) + qtbot.add_widget(prompt) + with qtbot.wait_signal(prompt._file_model.directoryLoaded): + pass + assert prompt._lineedit.text() == path + + return prompt + return _get_prompt_func + + @pytest.mark.parametrize('steps, where, subfolder', [ + (1, 'next', '..'), + (1, 'prev', 'c'), + (2, 'next', 'a'), + (2, 'prev', 'b'), + ]) + def test_simple_completion(self, tmpdir, get_prompt, steps, where, + subfolder): + """Simply trying to tab through items.""" + for directory in 'abc': + (tmpdir / directory).ensure(dir=True) + + prompt = get_prompt(str(tmpdir) + os.sep) + + for _ in range(steps): + prompt.item_focus(where) + + assert prompt._lineedit.text() == str(tmpdir / subfolder) + + def test_backspacing_path(self, qtbot, tmpdir, get_prompt): + """When we start deleting a path we want to see the subdir.""" + for directory in ['bar', 'foo']: + (tmpdir / directory).ensure(dir=True) + + prompt = get_prompt(str(tmpdir / 'foo') + os.sep) + + # Deleting /f[oo/] + with qtbot.wait_signal(prompt._file_model.directoryLoaded): + for _ in range(3): + qtbot.keyPress(prompt._lineedit, Qt.Key_Backspace) + + # We should now show / again, so tabbing twice gives us .. -> bar + prompt.item_focus('next') + prompt.item_focus('next') + assert prompt._lineedit.text() == str(tmpdir / 'bar') + + @pytest.mark.linux + def test_root_path(self, get_prompt): + """With / as path, show root contents.""" + prompt = get_prompt('/') + assert prompt._file_model.rootPath() == '/' diff --git a/tests/unit/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py index bb84521d0..b9a95c810 100644 --- a/tests/unit/mainwindow/test_tabwidget.py +++ b/tests/unit/mainwindow/test_tabwidget.py @@ -72,5 +72,6 @@ class TestTabWidget: icon = QIcon(pixmap) tab = fake_web_tab() widget.addTab(tab, icon, 'foobar') - widget.show() - qtbot.waitForWindowShown(widget) + + with qtbot.waitExposed(widget): + widget.show() diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py index aeef3bee5..a4093382c 100644 --- a/tests/unit/misc/test_keyhints.py +++ b/tests/unit/misc/test_keyhints.py @@ -50,10 +50,13 @@ def keyhint(qtbot, config_stub, key_config_stub): 'colors': { 'keyhint.fg': 'white', 'keyhint.fg.suffix': 'yellow', - 'keyhint.bg': 'black' + 'keyhint.bg': 'black', }, 'fonts': {'keyhint': 'Comic Sans'}, - 'ui': {'keyhint-blacklist': '', 'status-position': 'bottom'}, + 'ui': { + 'keyhint-blacklist': '', + 'status-position': 'bottom', + }, } keyhint = KeyHintView(0, None) qtbot.add_widget(keyhint) @@ -63,8 +66,8 @@ def keyhint(qtbot, config_stub, key_config_stub): def test_show_and_hide(qtbot, keyhint): with qtbot.waitSignal(keyhint.update_geometry): - keyhint.show() - qtbot.waitForWindowShown(keyhint) + with qtbot.waitExposed(keyhint): + keyhint.show() keyhint.update_keyhint('normal', '') assert not keyhint.isVisible() diff --git a/tests/unit/misc/test_lineparser.py b/tests/unit/misc/test_lineparser.py index 7cf7a73b7..f5fbb2f4d 100644 --- a/tests/unit/misc/test_lineparser.py +++ b/tests/unit/misc/test_lineparser.py @@ -21,14 +21,13 @@ import os import pytest +from unittest import mock from qutebrowser.misc import lineparser as lineparsermod class TestBaseLineParser: - """Tests for BaseLineParser.""" - CONFDIR = "this really doesn't matter" FILENAME = "and neither does this" @@ -53,6 +52,45 @@ class TestBaseLineParser: lineparser._prepare_save() os_mock.makedirs.assert_called_with(self.CONFDIR, 0o755) + def test_prepare_save_no_config(self, mocker): + """Test if _prepare_save doesn't create a None config dir.""" + os_mock = mocker.patch('qutebrowser.misc.lineparser.os') + os_mock.path.exists.return_value = True + + lineparser = lineparsermod.BaseLineParser(None, self.FILENAME) + assert not lineparser._prepare_save() + assert not os_mock.makedirs.called + + def test_double_open(self, mocker, lineparser): + """Test if _open refuses reentry.""" + mocker.patch('builtins.open', mock.mock_open()) + + with lineparser._open('r'): + with pytest.raises(IOError) as excinf: + with lineparser._open('r'): + pass + assert str(excinf.value) == 'Refusing to double-open AppendLineParser.' + + def test_binary(self, mocker): + """Test if _open and _write correctly handle binary files.""" + open_mock = mock.mock_open() + mocker.patch('builtins.open', open_mock) + + testdata = b'\xf0\xff' + + lineparser = lineparsermod.BaseLineParser( + self.CONFDIR, self.FILENAME, binary=True) + with lineparser._open('r') as f: + lineparser._write(f, [testdata]) + + open_mock.assert_called_once_with( + os.path.join(self.CONFDIR, self.FILENAME), 'rb') + + open_mock().write.assert_has_calls([ + mock.call(testdata), + mock.call(b'\n') + ]) + class TestLineParser: @@ -63,7 +101,18 @@ class TestLineParser: lp.save() return lp + def test_init(self, tmpdir): + """Test if creating a line parser correctly reads its file.""" + (tmpdir / 'file').write('one\ntwo\n') + lineparser = lineparsermod.LineParser(str(tmpdir), 'file') + assert lineparser.data == ['one', 'two'] + + (tmpdir / 'file').write_binary(b'\xfe\n\xff\n') + lineparser = lineparsermod.LineParser(str(tmpdir), 'file', binary=True) + assert lineparser.data == [b'\xfe', b'\xff'] + def test_clear(self, tmpdir, lineparser): + """Test if clear() empties its file.""" lineparser.data = ['one', 'two'] lineparser.save() assert (tmpdir / 'file').read() == 'one\ntwo\n' @@ -71,11 +120,23 @@ class TestLineParser: assert not lineparser.data assert (tmpdir / 'file').read() == '' + def test_double_open(self, lineparser): + """Test if save() bails on an already open file.""" + with lineparser._open('r'): + with pytest.raises(IOError): + lineparser.save() + + def test_prepare_save(self, tmpdir, lineparser): + """Test if save() bails when _prepare_save() returns False.""" + (tmpdir / 'file').write('pristine\n') + lineparser.data = ['changed'] + lineparser._prepare_save = lambda: False + lineparser.save() + assert (tmpdir / 'file').read() == 'pristine\n' + class TestAppendLineParser: - """Tests for AppendLineParser.""" - BASE_DATA = ['old data 1', 'old data 2'] @pytest.fixture @@ -97,7 +158,17 @@ class TestAppendLineParser: lineparser.save() assert (tmpdir / 'file').read() == self._get_expected(new_data) + def test_save_without_configdir(self, tmpdir, lineparser): + """Test save() failing because no configdir was set.""" + new_data = ['new data 1', 'new data 2'] + lineparser.new_data = new_data + lineparser._configdir = None + assert not lineparser.save() + # make sure new data is still there + assert lineparser.new_data == new_data + def test_clear(self, tmpdir, lineparser): + """Check if calling clear() empties both pending and persisted data.""" lineparser.new_data = ['one', 'two'] lineparser.save() assert (tmpdir / 'file').read() == "old data 1\nold data 2\none\ntwo\n" @@ -108,6 +179,14 @@ class TestAppendLineParser: assert not lineparser.new_data assert (tmpdir / 'file').read() == "" + def test_clear_without_configdir(self, tmpdir, lineparser): + """Test clear() failing because no configdir was set.""" + new_data = ['new data 1', 'new data 2'] + lineparser.new_data = new_data + lineparser._configdir = None + assert not lineparser.clear() + assert lineparser.new_data == new_data + def test_iter_without_open(self, lineparser): """Test __iter__ without having called open().""" with pytest.raises(ValueError): diff --git a/tests/unit/misc/test_utilcmds.py b/tests/unit/misc/test_utilcmds.py new file mode 100644 index 000000000..41c2f1f15 --- /dev/null +++ b/tests/unit/misc/test_utilcmds.py @@ -0,0 +1,148 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for qutebrowser.misc.utilcmds.""" + +import contextlib +import logging +import os +import pytest +import signal +import time + +from qutebrowser.misc import utilcmds + +from qutebrowser.commands import cmdexc + + +@contextlib.contextmanager +def _trapped_segv(handler): + """Temporarily install given signal handler for SIGSEGV.""" + old_handler = signal.signal(signal.SIGSEGV, handler) + yield + signal.signal(signal.SIGSEGV, old_handler) + + +def test_debug_crash_exception(): + """Verify that debug_crash crashes as intended.""" + with pytest.raises(Exception) as excinfo: + utilcmds.debug_crash(typ='exception') + assert str(excinfo.value) == 'Forced crash' + + +@pytest.mark.skipif(os.name == 'nt', + reason="current CPython/win can't recover from SIGSEGV") +def test_debug_crash_segfault(): + """Verify that debug_crash crashes as intended.""" + caught = False + + def _handler(num, frame): + """Temporary handler for segfault.""" + nonlocal caught + caught = num == signal.SIGSEGV + + with _trapped_segv(_handler): + # since we handle the segfault, execution will continue and run into + # the "Segfault failed (wat.)" Exception + with pytest.raises(Exception) as excinfo: + utilcmds.debug_crash(typ='segfault') + time.sleep(0.001) + assert caught + assert 'Segfault failed' in str(excinfo.value) + + +def test_debug_trace(mocker): + """Check if hunter.trace is properly called.""" + # but only if hunter is available + pytest.importorskip('hunter') + hunter_mock = mocker.patch('qutebrowser.misc.utilcmds.hunter') + utilcmds.debug_trace(1) + assert hunter_mock.trace.assert_called_with(1) + + +def test_debug_trace_exception(mocker): + """Check that exceptions thrown by hunter.trace are handled.""" + def _mock_exception(): + """Side effect for testing debug_trace's reraise.""" + raise Exception('message') + + hunter_mock = mocker.patch('qutebrowser.misc.utilcmds.hunter') + hunter_mock.trace.side_effect = _mock_exception + with pytest.raises(cmdexc.CommandError) as excinfo: + utilcmds.debug_trace() + assert str(excinfo.value) == 'Exception: message' + + +def test_debug_trace_no_hunter(monkeypatch): + """Test that an error is shown if debug_trace is called without hunter.""" + monkeypatch.setattr(utilcmds, 'hunter', None) + with pytest.raises(cmdexc.CommandError) as excinfo: + utilcmds.debug_trace() + assert str(excinfo.value) == "You need to install 'hunter' to use this " \ + "command!" + + +def test_repeat_command_initial(mocker, mode_manager): + """Test repeat_command first-time behavior. + + If :repeat-command is called initially, it should err, because there's + nothing to repeat. + """ + objreg_mock = mocker.patch('qutebrowser.misc.utilcmds.objreg') + objreg_mock.get.return_value = mode_manager + with pytest.raises(cmdexc.CommandError) as excinfo: + utilcmds.repeat_command(win_id=0) + assert str(excinfo.value) == "You didn't do anything yet." + + +def test_debug_log_level(mocker): + """Test interactive log level changing.""" + formatter_mock = mocker.patch( + 'qutebrowser.misc.utilcmds.log.change_console_formatter') + handler_mock = mocker.patch( + 'qutebrowser.misc.utilcmds.log.console_handler') + utilcmds.debug_log_level(level='debug') + formatter_mock.assert_called_with(logging.DEBUG) + handler_mock.setLevel.assert_called_with(logging.DEBUG) + + +class FakeWindow: + + """Mock class for window_only.""" + + def __init__(self, deleted=False): + self.closed = False + self.deleted = deleted + + def close(self): + """Flag as closed.""" + self.closed = True + + +def test_window_only(mocker, monkeypatch): + """Verify that window_only doesn't close the current or deleted windows.""" + test_windows = {0: FakeWindow(), 1: FakeWindow(True), 2: FakeWindow()} + winreg_mock = mocker.patch('qutebrowser.misc.utilcmds.objreg') + winreg_mock.window_registry = test_windows + sip_mock = mocker.patch('qutebrowser.misc.utilcmds.sip') + sip_mock.isdeleted.side_effect = lambda window: window.deleted + utilcmds.window_only(current_win_id=0) + assert not test_windows[0].closed + assert not test_windows[1].closed + assert test_windows[2].closed diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py index b3d3dd2dc..8ac783505 100644 --- a/tests/unit/utils/test_qtutils.py +++ b/tests/unit/utils/test_qtutils.py @@ -771,7 +771,7 @@ class TestPyQIODevice: with pytest.raises(io.UnsupportedOperation): pyqiodev.seek(0, whence) - @pytest.mark.flaky(reruns=1) + @pytest.mark.flaky() def test_qprocess(self, py_proc): """Test PyQIODevice with a QProcess which is non-sequential. diff --git a/tox.ini b/tox.ini index 8754a5783..22e584667 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,6 @@ setenv = PYTEST_QT_API=pyqt5 passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI TRAVIS XDG_* QUTE_* deps = - -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-tests.txt commands = @@ -93,7 +92,6 @@ commands = [testenv:vulture] basepython = python3 deps = - -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-vulture.txt setenv = PYTHONPATH={toxinidir} @@ -137,7 +135,6 @@ commands = basepython = python3 passenv = deps = - -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/misc/requirements/requirements-pyroma.txt commands = {envdir}/bin/pyroma . @@ -146,7 +143,6 @@ commands = basepython = python3 passenv = deps = - -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/misc/requirements/requirements-check-manifest.txt commands = {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__' @@ -156,7 +152,6 @@ basepython = python3 whitelist_externals = git passenv = TRAVIS_PULL_REQUEST deps = - -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/requirements.txt commands = {envpython} scripts/link_pyqt.py --tox {envdir} @@ -169,7 +164,6 @@ commands = # fail if we didn't have a fallback defined. basepython = {env:PYTHON:}/python.exe deps = - -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-cxfreeze.txt commands = @@ -180,7 +174,6 @@ commands = [testenv:pyinstaller] basepython = python3 deps = - -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-pyinstaller.txt commands = diff --git a/www/header.asciidoc b/www/header.asciidoc index a2492e9b0..f2095bd71 100644 --- a/www/header.asciidoc +++ b/www/header.asciidoc @@ -8,6 +8,7 @@