summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarc Jauvin <marc.jauvin@gmail.com>2018-03-16 14:28:36 -0400
committerMarc Jauvin <marc.jauvin@gmail.com>2018-03-16 14:28:36 -0400
commitb7159d780a69daf104da939438938d262cd86000 (patch)
treeab9dc382617174d366dae51cda89186cfbd2bcb9
parentc9f6cd507b55dabe4d4d8f7841955837a634ff20 (diff)
parentf7074b80d0a68eec6fdfd13f2f82acc94ff2951e (diff)
downloadqutebrowser-b7159d780a69daf104da939438938d262cd86000.tar.gz
qutebrowser-b7159d780a69daf104da939438938d262cd86000.zip
Merge 'origin/master' into tab-input-mode
-rw-r--r--.flake810
-rw-r--r--.travis.yml4
-rw-r--r--MANIFEST.in3
-rw-r--r--README.asciidoc6
-rw-r--r--doc/backers.asciidoc61
-rw-r--r--doc/changelog.asciidoc191
-rw-r--r--doc/contributing.asciidoc9
-rw-r--r--doc/faq.asciidoc58
-rw-r--r--doc/help/commands.asciidoc27
-rw-r--r--doc/help/configuring.asciidoc25
-rw-r--r--doc/help/settings.asciidoc80
-rw-r--r--doc/img/cheatsheet-big.pngbin1048195 -> 1100823 bytes
-rw-r--r--doc/img/cheatsheet-small.pngbin45328 -> 47520 bytes
-rw-r--r--doc/install.asciidoc19
-rw-r--r--doc/quickstart.asciidoc4
-rw-r--r--misc/cheatsheet.svg80
-rw-r--r--misc/requirements/requirements-codecov.txt2
-rw-r--r--misc/requirements/requirements-flake8.txt4
-rw-r--r--misc/requirements/requirements-pip.txt4
-rw-r--r--misc/requirements/requirements-pylint-master.txt2
-rw-r--r--misc/requirements/requirements-pylint.txt2
-rw-r--r--misc/requirements/requirements-pyqt-old.txt4
-rw-r--r--misc/requirements/requirements-pyqt-old.txt-raw2
-rw-r--r--misc/requirements/requirements-pyqt.txt4
-rw-r--r--misc/requirements/requirements-tests.txt12
-rwxr-xr-xmisc/userscripts/open_download2
-rwxr-xr-xmisc/userscripts/password_fill2
-rwxr-xr-xmisc/userscripts/readability6
-rwxr-xr-xmisc/userscripts/tor_identity52
-rw-r--r--pytest.ini1
-rw-r--r--qutebrowser/__init__.py2
-rw-r--r--qutebrowser/app.py14
-rw-r--r--qutebrowser/browser/browsertab.py45
-rw-r--r--qutebrowser/browser/commands.py85
-rw-r--r--qutebrowser/browser/downloads.py8
-rw-r--r--qutebrowser/browser/greasemonkey.py152
-rw-r--r--qutebrowser/browser/hints.py13
-rw-r--r--qutebrowser/browser/mouse.py5
-rw-r--r--qutebrowser/browser/network/proxy.py4
-rw-r--r--qutebrowser/browser/qutescheme.py25
-rw-r--r--qutebrowser/browser/shared.py15
-rw-r--r--qutebrowser/browser/signalfilter.py4
-rw-r--r--qutebrowser/browser/urlmarks.py2
-rw-r--r--qutebrowser/browser/webelem.py9
-rw-r--r--qutebrowser/browser/webengine/webengineinspector.py4
-rw-r--r--qutebrowser/browser/webengine/webenginesettings.py406
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py142
-rw-r--r--qutebrowser/browser/webengine/webview.py52
-rw-r--r--qutebrowser/browser/webkit/webkitsettings.py258
-rw-r--r--qutebrowser/browser/webkit/webkittab.py62
-rw-r--r--qutebrowser/browser/webkit/webpage.py66
-rw-r--r--qutebrowser/browser/webkit/webview.py4
-rw-r--r--qutebrowser/commands/__init__.py1
-rw-r--r--qutebrowser/commands/runners.py4
-rw-r--r--qutebrowser/completion/completer.py2
-rw-r--r--qutebrowser/completion/models/configmodel.py40
-rw-r--r--qutebrowser/completion/models/histcategory.py2
-rw-r--r--qutebrowser/completion/models/miscmodels.py6
-rw-r--r--qutebrowser/config/config.py236
-rw-r--r--qutebrowser/config/configcommands.py94
-rw-r--r--qutebrowser/config/configdata.py10
-rw-r--r--qutebrowser/config/configdata.yml61
-rw-r--r--qutebrowser/config/configexc.py22
-rw-r--r--qutebrowser/config/configfiles.py244
-rw-r--r--qutebrowser/config/configtypes.py47
-rw-r--r--qutebrowser/config/configutils.py186
-rw-r--r--qutebrowser/config/websettings.py261
-rw-r--r--qutebrowser/html/settings.html2
-rw-r--r--qutebrowser/html/tabs.html58
-rw-r--r--qutebrowser/html/undef_error.html22
-rw-r--r--qutebrowser/javascript/greasemonkey_wrapper.js38
-rw-r--r--qutebrowser/javascript/webelem.js9
-rw-r--r--qutebrowser/keyinput/basekeyparser.py299
-rw-r--r--qutebrowser/keyinput/keyparser.py77
-rw-r--r--qutebrowser/keyinput/keyutils.py558
-rw-r--r--qutebrowser/keyinput/modeman.py75
-rw-r--r--qutebrowser/keyinput/modeparsers.py201
-rw-r--r--qutebrowser/mainwindow/mainwindow.py12
-rw-r--r--qutebrowser/mainwindow/prompt.py31
-rw-r--r--qutebrowser/mainwindow/statusbar/backforward.py2
-rw-r--r--qutebrowser/mainwindow/statusbar/bar.py2
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py138
-rw-r--r--qutebrowser/mainwindow/tabwidget.py16
-rw-r--r--qutebrowser/misc/earlyinit.py5
-rw-r--r--qutebrowser/misc/editor.py41
-rw-r--r--qutebrowser/misc/keyhintwidget.py10
-rw-r--r--qutebrowser/misc/miscwidgets.py16
-rw-r--r--qutebrowser/misc/pastebin.py6
-rw-r--r--qutebrowser/misc/sessions.py9
-rw-r--r--qutebrowser/misc/utilcmds.py2
-rw-r--r--qutebrowser/utils/jinja.py11
-rw-r--r--qutebrowser/utils/objreg.py2
-rw-r--r--qutebrowser/utils/urlmatch.py293
-rw-r--r--qutebrowser/utils/usertypes.py22
-rw-r--r--qutebrowser/utils/utils.py290
-rw-r--r--qutebrowser/utils/version.py33
-rwxr-xr-xscripts/asciidoc2html.py4
-rwxr-xr-xscripts/dev/build_release.py4
-rw-r--r--scripts/dev/check_coverage.py8
-rw-r--r--scripts/dev/ci/travis_install.sh7
-rwxr-xr-xscripts/dev/src2asciidoc.py4
-rw-r--r--scripts/hostblock_blame.py55
-rw-r--r--scripts/keytester.py4
-rw-r--r--tests/conftest.py11
-rw-r--r--tests/end2end/conftest.py23
-rw-r--r--tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht1
-rw-r--r--tests/end2end/data/hints/issue3711.html13
-rw-r--r--tests/end2end/data/hints/issue3711_frame.html11
-rw-r--r--tests/end2end/data/javascript/enabled.html16
-rw-r--r--tests/end2end/data/javascript/localstorage.html2
-rw-r--r--tests/end2end/features/caret.feature25
-rw-r--r--tests/end2end/features/editor.feature1
-rw-r--r--tests/end2end/features/hints.feature13
-rw-r--r--tests/end2end/features/javascript.feature23
-rw-r--r--tests/end2end/features/keyinput.feature11
-rw-r--r--tests/end2end/features/misc.feature19
-rw-r--r--tests/end2end/features/search.feature12
-rw-r--r--tests/end2end/features/tabs.feature8
-rw-r--r--tests/end2end/fixtures/test_webserver.py36
-rw-r--r--tests/end2end/fixtures/webserver.py34
-rw-r--r--tests/end2end/fixtures/webserver_sub.py28
-rw-r--r--tests/end2end/test_invocations.py1
-rw-r--r--tests/end2end/test_mhtml_e2e.py10
-rw-r--r--tests/helpers/fixtures.py42
-rw-r--r--tests/helpers/stubs.py136
-rw-r--r--tests/unit/browser/test_adblock.py58
-rw-r--r--tests/unit/browser/test_signalfilter.py16
-rw-r--r--tests/unit/browser/urlmarks.py126
-rw-r--r--tests/unit/browser/webengine/test_webenginesettings.py49
-rw-r--r--tests/unit/completion/test_completer.py21
-rw-r--r--tests/unit/completion/test_histcategory.py17
-rw-r--r--tests/unit/completion/test_models.py140
-rw-r--r--tests/unit/config/test_config.py354
-rw-r--r--tests/unit/config/test_configcommands.py214
-rw-r--r--tests/unit/config/test_configexc.py14
-rw-r--r--tests/unit/config/test_configfiles.py361
-rw-r--r--tests/unit/config/test_configinit.py65
-rw-r--r--tests/unit/config/test_configtypes.py38
-rw-r--r--tests/unit/config/test_configutils.py210
-rw-r--r--tests/unit/javascript/test_greasemonkey.py30
-rw-r--r--tests/unit/keyinput/conftest.py22
-rw-r--r--tests/unit/keyinput/key_data.py623
-rw-r--r--tests/unit/keyinput/test_basekeyparser.py366
-rw-r--r--tests/unit/keyinput/test_keyutils.py545
-rw-r--r--tests/unit/keyinput/test_modeman.py19
-rw-r--r--tests/unit/keyinput/test_modeparsers.py50
-rw-r--r--tests/unit/mainwindow/statusbar/test_backforward.py12
-rw-r--r--tests/unit/mainwindow/test_prompt.py23
-rw-r--r--tests/unit/mainwindow/test_tabwidget.py4
-rw-r--r--tests/unit/misc/test_editor.py39
-rw-r--r--tests/unit/misc/test_miscwidgets.py2
-rw-r--r--tests/unit/misc/test_pastebin.py42
-rw-r--r--tests/unit/misc/test_utilcmds.py2
-rw-r--r--tests/unit/utils/test_jinja.py17
-rw-r--r--tests/unit/utils/test_urlmatch.py571
-rw-r--r--tests/unit/utils/test_utils.py170
-rw-r--r--tests/unit/utils/test_version.py88
-rw-r--r--tox.ini9
158 files changed, 7646 insertions, 2782 deletions
diff --git a/.flake8 b/.flake8
index 340132d49..5b0360ab7 100644
--- a/.flake8
+++ b/.flake8
@@ -44,11 +44,11 @@ ignore =
min-version = 3.4.0
max-complexity = 12
per-file-ignores =
- tests/*/test_*.py : D100,D101,D401
- tests/unit/browser/test_history.py : N806
- tests/helpers/fixtures.py : N806
- tests/unit/browser/webkit/http/test_content_disposition.py : D400
- scripts/dev/ci/appveyor_install.py : FI53
+ /tests/*/test_*.py : D100,D101,D401
+ /tests/unit/browser/test_history.py : N806
+ /tests/helpers/fixtures.py : N806
+ /tests/unit/browser/webkit/http/test_content_disposition.py : D400
+ /scripts/dev/ci/appveyor_install.py : FI53
copyright-check = True
copyright-regexp = # Copyright [\d-]+ .*
copyright-min-file-size = 110
diff --git a/.travis.yml b/.travis.yml
index d5ebb1dec..d2167d217 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -15,10 +15,8 @@ matrix:
- os: linux
env: TESTENV=py36-pyqt571
- os: linux
- env: TESTENV=py36-pyqt58
- - os: linux
python: 3.5
- env: TESTENV=py35-pyqt59
+ env: TESTENV=py35-pyqt571
- os: linux
env: TESTENV=py36-pyqt59-cov
- os: linux
diff --git a/MANIFEST.in b/MANIFEST.in
index 9dace6f98..242fea292 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -8,6 +8,7 @@ graft icons
graft doc/img
graft misc/apparmor
graft misc/userscripts
+graft misc/requirements
recursive-include scripts *.py *.sh *.js
include qutebrowser/utils/testfile
include qutebrowser/git-commit-id
@@ -32,8 +33,6 @@ include doc/qutebrowser.1.asciidoc
include doc/changelog.asciidoc
prune tests
prune qutebrowser/3rdparty
-prune misc/requirements
-prune misc/docker
exclude pytest.ini
exclude qutebrowser.rcc
exclude qutebrowser/javascript/.eslintrc.yaml
diff --git a/README.asciidoc b/README.asciidoc
index aed2f61e2..8dfc11c9f 100644
--- a/README.asciidoc
+++ b/README.asciidoc
@@ -44,8 +44,8 @@ Documentation
In addition to the topics mentioned in this README, the following documents are
available:
-* 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"]
+* https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png[Key binding cheatsheet]: +
+image:https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png"]
* link:doc/quickstart.asciidoc[Quick start guide]
* https://www.shortcutfoo.com/app/dojos/qutebrowser[Free training course] to remember those key bindings
* link:doc/faq.asciidoc[Frequently asked questions]
@@ -91,7 +91,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
-https://www.the-compiler.org/pubkey.asc[0xFD55A072].
+https://www.the-compiler.org/pubkey.asc[0x916eb0c8fd55a072].
Requirements
------------
diff --git a/doc/backers.asciidoc b/doc/backers.asciidoc
index 2dd6f52b3..80f46fd6e 100644
--- a/doc/backers.asciidoc
+++ b/doc/backers.asciidoc
@@ -13,47 +13,75 @@ Thanks a lot to the following people who contributed to it:
Gold sponsors
~~~~~~~~~~~~~
-TODO
+- Iggy
+- zwitschi
+- 2x Anonymous
Silver sponsors
~~~~~~~~~~~~~~~
-TODO
+- https://benary.org[benaryorg]
+- https://scratchbook.ch[Claude]
+- Martin Tournoij
+- http://supported.elsensohn.ch[Thomas Elsensohn]
+- Christian Helbling
+- Gavin Troy
+- Chris King-Parra
+- Tim Das Mool Wegener
Other sponsors
~~~~~~~~~~~~~~
-TODO: people with t-shirts or higher pledge levels
-
- 7scan
+- AMD1212
+- Alex
- Alex Suykov
- Alexey Zhikhartsev
- Allan Nordhøy
- Anirudh Sanjeev
- Anssi Puustinen
+- Anton Grensjö
+- Aristaeus
+- Armin Fisslthaler
+- Ashley Hauck
- Benedikt Steindorf
- Bernardo Kuri
- Blaise Duszynski
- Bostan
- Bruno Oliveira
+- BunnyApocalypse
+- Christian Kellermann
- Colin Jacobs
- Daniel Andersson
+- Daniel Nelson
+- Daniel P. Schmidt
+- Daniel Salby
- Danilo
- David Beley
- David Hollings
+- David Keijser
- David Parrish
- Derin Yarsuvat
- Dmytro Kostiuchenko
+- Eero Kari
+- Epictek
+- Eric
+- Faure Hu
+- Ferus
- Frederik Thorøe
- G4v4g4i
+- Granitosaurus
- Gyula Teleki
- H
+- Heinz Bruhin
- Hosaka
+- Ihor Radchenko
- Iordanis Grigoriou
- Isaac Sandaljian
- Jakub Podeszwik
- Jamie Anderson
- Jasper Woudenberg
+- Jay Kamat
- Jens Højgaard
- Johannes
- John Baber-Lucero
@@ -61,9 +89,11 @@ TODO: people with t-shirts or higher pledge levels
- Kenichiro Ito
- Kenny Low
- Lars Ivar Igesund
+- Leulas
- Lucas Aride Moulin
- Ludovic Chabant
- Lukas Gierth
+- Magnus Lindström
- Marulkan
- Matthew Chun-Lum
- Matthew Cronen
@@ -80,7 +110,10 @@ TODO: people with t-shirts or higher pledge levels
- Peter Rice
- Philipp Middendorf
- Pkill9
+- PluMGMK
- Prescott
+- ProXicT
+- Ram-Z
- Robotichead
- Roshless
- Ryan Ellis
@@ -90,35 +123,53 @@ TODO: people with t-shirts or higher pledge levels
- Sean Herman
- Sebastian Frysztak
- Shelby Cruver
+- Simon Désaulniers
- SirCmpwn
- Soham Pal
+- Stephan Jauernick
- Stewart Webb
- Sven Reinecke
+- Timothée Floure
- Tom Bass
+- Tom Kirchner
- Tomas Slusny
- Tomasz Kramkowski
- Tommy Thomas
+- Tuscan
+- Ulrich Pötter
- Vasilij Schneidermann
- Vlaaaaaaad
+- XTaran
+- Z2h-A6n
+- ayekat
- beanieuptop
+- cee
+- craftyguy
- demure
+- dlangevi
+- epon
- evenorbert
- fishss
- gsnewmark
- guillermohs9
+- hernani
- hubcaps
+- jnphilipp
- lobachevsky
- neodarz
- nihlaeth
- notbenh
+- nyctea
+- ongy
- patrick suwanvithaya
- pyratebeard
+- p≡p foundation
- randm_dave
- sabreman
- toml
- vimja
- wiz
-- 44 Anonymous
+- 48 Anonymous
2016
----
diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc
index 3375fe6a0..d02dfcc2a 100644
--- a/doc/changelog.asciidoc
+++ b/doc/changelog.asciidoc
@@ -15,85 +15,198 @@ breaking changes (such as renamed commands) can happen in minor releases.
// `Fixed` for any bug fixes.
// `Security` to invite users to upgrade in case of vulnerabilities.
-v1.2.0 (unreleased)
+v1.3.0 (unreleased)
-------------------
+Changed
+~~~~~~~
+
+- The file dialog for downloads now has basic tab completion based on the
+ entered text.
+- `:version` now shows OS information for POSIX OS other than Linux/macOS.
+- When there's an error inserting the text from an external editor, a backup
+ file is now saved.
+
+Fixed
+~~~~~
+
+- Using hints before a page is fully loaded is now possible again.
+- Tab titles for tabs loaded from sessions should now really be correct instead
+ of showing the URL.
+- Loading URLs with customized settings from a session now avoids an additional
+ reload.
+- The window icon and title now get set correctly again.
+
+v1.2.1
+------
+
+Fixed
+~~~~~
+
+- qutebrowser now starts properly when the PyQt5 QOpenGLFunctions package wasn't
+ found.
+- The keybinding cheatsheet on the quickstart page is now loaded from a local
+ `qute://` URL again.
+- With "tox -e mkvenv-pypi", PyQt 5.10.0 is used again instead of Qt 5.10.1,
+ because of an issue with Qt 5.10.1 which causes qutebrowser to fail to start
+ ("Could not find QtWebEngineProcess").
+- Unbinding keys which were bound in older qutebrowser versions now doesn't
+ crash anymore.
+- Fixed a crash when reloading a page which wasn't fully loaded with v1.2.0
+- Keys on the numeric keypad now fall back to the same bindings without `Num+`
+ if no `Num+` binding was found.
+- Fixed hinting on some pages with Qt < 5.10.
+- Titles are now displayed correctly again for tabs which are cloned or loaded
+ from sessions.
+- Shortcuts now correctly use `Ctrl` instead of `Command` on macOS again.
+
+v1.2.0
+------
+
Added
~~~~~
-- QtWebEngine: Caret/visual mode is now supported.
-- QtWebEngine: Authentication via ~/.netrc is now supported.
-- A new `qute://bindings` page, opened by `:bind`, shows all keybindings.
-- `:session-load` has a new `--delete` flag which deletes the
- session after loading it.
-- QtWebEngine: Retrying downloads is now supported with Qt 5.10 or newer.
-- QtWebEngine: Hinting and other features inside same-origin frames is now
- supported.
-- New `cycle-inputs.js` script in `scripts/` which can be used with `:jseval -f`
- to cycle through inputs.
-- New `--no-last` flag for `:tab-focus` to not focus the last tab when focusing
- the currently focused one.
-- New `--edit` flag for `:view-source` to open the source in an external editor.
-- New `statusbar.widgets` setting to configure which widgets should be shown in
- which order in the statusbar.
+- Initial implementation of per-domain settings:
+ * `:set` and `:config-cycle` now have a `-u`/`--pattern` argument taking a
+ https://developer.chrome.com/extensions/match_patterns[URL match pattern]
+ for supported settings.
+ * `config.set` in `config.py` now takes a third argument which is the pattern.
+ * New `with config.pattern('...') as p:` context manager for `config.py` to
+ use the shorthand syntax with a pattern.
+ * New `tsh` keybinding to toggle scripts for the current host. With a capital
+ `S`, the toggle is saved. With a capital `H`, subdomains are included. With
+ `u` instead of `h`, the exact current URL is used.
+ * New `tph` keybinding to toggle plugins, with the same additional binding
+ described above.
+- New QtWebEngine features:
+ * Caret/visual mode
+ * Authentication via ~/.netrc
+ * Retrying downloads with Qt 5.10 or newer
+ * Hinting and other features inside same-origin frames
+- New flags for existing commands:
+ * `:session-load` has a new `--delete` flag which deletes the
+ session after loading it.
+ * New `--no-last` flag for `:tab-focus` to not focus the last tab when focusing
+ the currently focused one.
+ * New `--edit` flag for `:view-source` to open the source in an external editor.
+ * New `--select` flag for `:follow-hint` which acts like the given string was entered but doesn't necessary follow the hint.
+- New special pages:
+ * `qute://bindings` (opened via `:bind`) which shows all keybindings.
+ * `qute://tabs` (opened via `:buffer`) which lists all tabs.
+- New settings:
+ * `statusbar.widgets` to configure which widgets should be shown in which
+ order in the statusbar.
+ * `tabs.mode_on_change` which replaces `tabs.persist_mode_on_change`. It can
+ now be set to `restore` which remembers input modes (input/passthrough)
+ per tab.
+ * `input.insert_mode.auto_enter` which makes it possible to disable entering
+ insert mode automatically when an editable element was clicked. Together
+ with `input.forward_unbound_keys`, this should allow for emacs-like
+ "modeless" keybindings.
- New `:prompt-yank` command (bound to `Alt-y` by default) to yank URLs
referenced in prompts.
+- The `hostblock_blame` script which was removed in v1.0 was updated for the new
+ config and re-added.
+- New `cycle-inputs.js` script in `scripts/` which can be used with `:jseval -f`
+ to cycle through inputs.
Changed
~~~~~~~
+- Complete refactoring of key input handling, with various effects:
+ * emacs-like keychains such as `<Ctrl-X><Ctrl-C>` can now be bound.
+ * Key chains can now be bound in any mode (this allows binding unused keys in
+ hint mode).
+ * Yes/no prompts don't use keybindings from the `prompt` section anymore, they
+ have their own `yesno` section instead.
+ * Trying to bind invalid keys now shows an error.
+ * The `bindings.default` setting can now only be set in a `config.py`, and
+ existing values in `autoconfig.yml` are ignored.
+- Improvements for GreaseMonkey support:
+ * `@include` and `@exclude` now support regex matches. With QtWebEngine and Qt
+ 5.8 and newer, Qt handles the matching, but similar functionality will be
+ added in Qt 5.11.
+ * Support for `@requires`
+ * Support for the GreaseMonkey 4.0 API
+- The sqlite history now uses write-ahead logging which should be
+ a performance and stability improvement.
+- When an editor is spawned with `:open-editor` and `:config-edit`, the changes
+ are now applied as soon as the file is saved in the editor.
- The `hist_importer.py` script now only imports URL schemes qutebrowser can
handle.
- Deleting a prefix (`:`, `/` or `?`) via backspace now leaves command mode.
-- Angular 1 elements now get hints assigned.
+- Angular 1 elements and `<summary>`/`<details>` now get hints assigned.
- `:tab-only` with pinned tabs now still closes unpinned tabs.
-- GreaseMonkey `@include` and `@exclude` now support
- regex matches. With QtWebEngine and Qt 5.8 and newer, Qt handles the matching,
- but similar functionality was added in Qt 5.11.
-- The sqlite history now uses write-ahead logging which should be
- a performance and stability improvement.
- The `url.incdec_segments` option now also can take `port` as possible segment.
- QtWebEngine: `:view-source` now uses Chromium's `view-source:` scheme.
- Tabs now show their full title as tooltip.
-- When an editor is spawned with `:open-editor` and `:config-edit`, the changes
- are now applied as soon as the file is saved in the editor.
- When there are multiple unknown keys in a autoconfig.yml, they now all get
reported in one error.
-- New `tabs.mode_on_change` setting which replaces
- `tabs.persist_mode_on_change`. It can now be set to `restore` which remembers
- input modes (input/passthrough) per tab.
- More performance improvements when opening/closing many tabs.
- The `:version` page now has a button to pastebin the information.
+- Replacements like `{url}` can now be escaped as `{{url}}`.
Fixed
~~~~~
-- QtWebEngine: Improved fullscreen handling with Qt 5.10.
-- QtWebEngine: Hinting and scrolling now works properly on special
- `view-source:` pages.
-- QtWebEngine: Scroll positions are now restored correctly from sessions.
-- QtWebKit: `:view-source` now displays a valid URL.
-- URLs containing ampersands and other special chars are now shown
- correctly when filtering them in the completion.
+- QtWebEngine bugfixes:
+ * Improved fullscreen handling with Qt 5.10.
+ * Hinting and scrolling now works properly on special `view-source:` pages.
+ * Scroll positions are now restored correctly from sessions.
+ * `:follow-selected` should now work in more cases with Qt > 5.10.
+ * Incremental search now flickers less and doesn't move to the second result
+ when pressing Enter.
+ * Keys like `Ctrl-V` or `Shift-Insert` are now correctly handled/filtered with
+ Qt 5.10.
+ * Fixed hangs/segfaults on exit with Qt 5.10.1.
+ * Fixed favicons sometimes getting cleared with Qt 5.10.
+ * Qt download objects are now cleaned up properly when a download is removed.
+ * JavaScript messages are now not double-HTML escaped anymore on Qt < 5.11
+- QtWebKit bugfixes:
+ * Fixed GreaseMonkey-related crashes.
+ * `:view-source` now displays a valid URL.
+- URLs containing ampersands and other special chars are now shown correctly
+ when filtering them in the completion.
- `:bookmark-add "" foo` can now be used to save the current URL with a custom
title.
- `:spawn -o` now waits until the process has finished before trying to show the
output. Previously, it incorrectly showed the previous output immediately.
-- QtWebEngine: Qt download objects are now cleaned up properly when a download
- is removed.
- Suspended pages now should always load the correct page when being un-suspended.
-- Compatibility with Python 3.7
+- Exception types are now shown properly with `:config-source` and `:config-edit`.
+- When using `:bookmark-add --toggle`, bookmarks are now saved properly.
+- Crash when opening an invalid URL from an application on macOS.
+- Crash with an empty `completion.timestamp_format`.
+- Crash when `completion.min_chars` is set in some cases.
+- HTML/JS resource files are now read into RAM on start to avoid crashes when
+ changing qutebrowser versions while it's open.
+- Setting `bindings.key_mappings` to an empty value is now allowed.
+- Bindings to an empty commands are now ignored rather than crashing.
Removed
~~~~~~~
- `QUTE_SELECTED_HTML` is now not set for userscripts anymore except when called
via hints.
-- The `qutebrowser_viewsource` userscript has been removed as `:view-source
- --edit` can now be used.
+- The `qutebrowser_viewsource` userscript has been removed as
+ `:view-source --edit` can now be used.
- The `tabs.persist_mode_on_change` setting has been removed and replaced by
`tabs.mode_on_change`.
+v1.1.2
+------
+
+Changed
+~~~~~~~
+
+- Windows/macOS releases now bundle Qt 5.10.1 which includes security fixes from
+ Chromium up to version 64.0.3282.140.
+
+Fixed
+~~~~~
+
+- QtWebEngine: Crash with Qt 5.10.1 when using :undo on some tabs.
+- Compatibility with Python 3.7
+
v1.1.1
------
diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc
index a75034b95..2c95c125e 100644
--- a/doc/contributing.asciidoc
+++ b/doc/contributing.asciidoc
@@ -44,8 +44,8 @@ be easy to solve]
If you prefer C++ or Javascript to Python, see the relevant issues which involve
work in those languages:
-* https://github.com/qutebrowser/qutebrowser/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3Ac%2B%2B[C++] (mostly work on Qt, the library behind qutebrowser)
-* https://github.com/qutebrowser/qutebrowser/issues?q=is%3Aopen+is%3Aissue+label%3Ajavascript[JavaScript]
+* https://github.com/qutebrowser/qutebrowser/issues?q=is%3Aopen+is%3Aissue+label%3A%22language%3A+c%2B%2B%22[C++] (mostly work on Qt, the library behind qutebrowser)
+* https://github.com/qutebrowser/qutebrowser/issues?q=is%3Aopen+is%3Aissue+label%3A%22language%3A+javascript%22[JavaScript]
There are also some things to do if you don't want to write code:
@@ -670,10 +670,11 @@ qutebrowser release
~~~~~~~~~~~~~~~~~~~
* Make sure there are no unstaged changes and the tests are green.
+* Make sure all issues with the related milestone are closed.
* Run `x=... y=...` to set the respective shell variables.
-* Adjust `__version_info__` in `qutebrowser/__init__.py`.
* Update changelog (remove *(unreleased)*).
+* Adjust `__version_info__` in `qutebrowser/__init__.py`.
* Commit.
* Create annotated git tag (`git tag -s "v1.$x.$y" -m "Release v1.$x.$y"`).
@@ -683,7 +684,7 @@ qutebrowser release
* Mark the milestone at https://github.com/qutebrowser/qutebrowser/milestones
as closed.
-* Linux: Run `git checkout v1.$x.$y && python3 scripts/dev/build_release.py --upload v1.$x.$y`.
+* Linux: Run `git checkout v1.$x.$y && ./.venv/bin/python3 scripts/dev/build_release.py --upload v1.$x.$y`.
* Windows: Run `git checkout v1.X.Y; C:\Python36-32\python scripts\dev\build_release.py --asciidoc C:\Python27\python C:\asciidoc-8.6.9\asciidoc.py --upload v1.X.Y` (replace X/Y by hand).
* macOS: Run `git checkout v1.X.Y && python3 scripts/dev/build_release.py --upload v1.X.Y` (replace X/Y by hand).
* On server: Run `python3 scripts/dev/download_release.py v1.X.Y` (replace X/Y by hand).
diff --git a/doc/faq.asciidoc b/doc/faq.asciidoc
index 8bbc1e5d0..5fd36d67b 100644
--- a/doc/faq.asciidoc
+++ b/doc/faq.asciidoc
@@ -32,7 +32,7 @@ When qutebrowser was created, the newer
http://webkitgtk.org/reference/webkit2gtk/stable/index.html[WebKit2 API] lacked
basic features like proxy support, and almost no projects have started porting
to WebKit2. In the meantime, this situation has improved a bit, but there are
-stil only a few project which have some kind of WebKit2 support (see the
+still only a few projects which have some kind of WebKit2 support (see the
https://github.com/qutebrowser/qutebrowser#similar-projects[list of
alternatives]).
+
@@ -70,6 +70,31 @@ But isn't Python too slow for a browser?::
and WebKit in C++, with the
https://wiki.python.org/moin/GlobalInterpreterLock[GIL] released.
+Is qutebrowser secure?::
+ Most security issues are in the backend (which handles networking,
+ rendering, JavaScript, etc.) and not qutebrowser itself.
++
+qutebrowser uses http://wiki.qt.io/QtWebEngine[QtWebEngine] by default.
+QtWebEngine is based on Google's https://www.chromium.org/Home[Chromium]. While
+Qt only updates to a new Chromium release on every minor Qt release (all ~6
+months), every patch release backports security fixes from newer Chromium
+versions. In other words: As long as you're using an up-to-date Qt, you should
+be recieving security updates on a regular basis, without qutebrowser having to
+do anything. Chromium's process isolation and
+https://chromium.googlesource.com/chromium/src/+/master/docs/design/sandbox.md[sandboxing]
+features are also enabled as a second line of defense.
++
+http://wiki.qt.io/QtWebKit[QtWebKit] is also supported as an alternative
+backend, but hasn't seen new releases
+https://github.com/annulen/webkit/releases[in a while]. It also doesn't have any
+process isolation or sandboxing.
++
+Security issues in qutebrowser's code happen very rarely (as per March 2018,
+there has been one security issue caused by qutebrowser in over four years) and
+are fixed timely. To report security bugs, please contact me directly at
+mail@qutebrowser.org, GPG ID
+https://www.the-compiler.org/pubkey.asc[0x916eb0c8fd55a072].
+
Is there an adblocker?::
There is a host-based adblocker which takes /etc/hosts-like lists. A "real"
adblocker has a
@@ -187,6 +212,37 @@ Why takes it longer to open an URL in qutebrowser than in chromium?::
qutebrowser if it is not running already. Also check if you want
to use webengine as backend in line 17 and change it to your
needs.
+
+How do I make qutebrowser use greasemonkey scripts?::
+ There is currently no UI elements to handle managing greasemonkey scripts.
+ All management of what scripts are installed or disabled is done in the
+ filesystem by you. qutebrowser reads all files that have an extension of
+ `.js` from the `<data>/greasemonkey/` folder and attempts to load them.
+ Where `<data>` is the qutebrowser data directory shown in the `Paths`
+ section of the page displayed by `:version`. If you want to disable a
+ script just rename it, for example, to have `.disabled` on the end, after
+ the `.js` extension. To reload scripts from that directory run the command
+ `:greasemonkey-reload`.
++
+Troubleshooting: to check that your script is being loaded when
+`:greasemonkey-reload` runs you can start qutebrowser with the arguments
+`--debug --logfilter greasemonkey,js` and check the messages on the
+program's standard output for errors parsing or loading your script.
+You may also see javascript errors if your script is expecting an environment
+that we fail to provide.
++
+Note that there are some missing features which you may run into:
+
+. Some scripts expect `GM_xmlhttpRequest` to ignore Cross Origin Resource
+ Sharing restrictions, this is currently not supported, so scripts making
+ requests to third party sites will often fail to function correctly.
+. If your backend is a QtWebEngine version 5.8, 5.9 or 5.10 then regular
+ expressions are not supported in `@include` or `@exclude` rules. If your
+ script uses them you can re-write them to use glob expressions or convert
+ them to `@match` rules.
+ See https://wiki.greasespot.net/Metadata_Block[the wiki] for more info.
+. Any greasemonkey API function to do with adding UI elements is not currently
+ supported. That means context menu extentensions and background pages.
== Troubleshooting
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index c5f013491..3a12f08eb 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -14,6 +14,7 @@ For command arguments, there are also some variables you can use:
- `{url}` expands to the URL of the current page
- `{url:pretty}` expands to the URL in decoded format
+- `{url:host}` expands to the host part of the URL
- `{clipboard}` expands to the clipboard contents
- `{primary}` expands to the primary selection contents
@@ -153,7 +154,8 @@ Bind a key to a command.
If no command is given, show the current binding for the given key. Using :bind without any arguments opens a page showing all keybindings.
==== positional arguments
-* +'key'+: The keychain or special key (inside `<...>`) to bind.
+* +'key'+: The keychain to bind. Examples of valid keychains are `gC`, `<Ctrl-X>` or `<Ctrl-C>a`.
+
* +'command'+: The command to execute, with optional args.
==== optional arguments
@@ -221,7 +223,7 @@ Syntax: +:buffer ['index']+
Select tab by index or url/title best match.
-Focuses window if necessary when index is given. If both index and count are given, use count.
+Focuses window if necessary when index is given. If both index and count are given, use count. With neither index nor count given, open the qute://tabs page.
==== positional arguments
* +'index'+: The [win_id/]index of the tab to focus. Or a substring in which case the closest match will be focused.
@@ -274,7 +276,8 @@ Set all settings back to their default.
[[config-cycle]]
=== config-cycle
-Syntax: +:config-cycle [*--temp*] [*--print*] 'option' ['values' ['values' ...]]+
+Syntax: +:config-cycle [*--pattern* 'pattern'] [*--temp*] [*--print*]
+ 'option' ['values' ['values' ...]]+
Cycle an option between multiple values.
@@ -283,6 +286,7 @@ Cycle an option between multiple values.
* +'values'+: The values to cycle through.
==== optional arguments
+* +*-u*+, +*--pattern*+: The URL pattern to use.
* +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed.
* +*-p*+, +*--print*+: Print the value after setting.
@@ -495,10 +499,16 @@ Toggle fullscreen mode.
[[greasemonkey-reload]]
=== greasemonkey-reload
+Syntax: +:greasemonkey-reload [*--force*]+
+
Re-read Greasemonkey scripts from disk.
The scripts are read from a 'greasemonkey' subdirectory in qutebrowser's data directory (see `:version`).
+==== optional arguments
+* +*-f*+, +*--force*+: For any scripts that have required dependencies, re-download them.
+
+
[[help]]
=== help
Syntax: +:help [*--tab*] [*--bg*] [*--window*] ['topic']+
@@ -1110,7 +1120,7 @@ Save a session.
[[set]]
=== set
-Syntax: +:set [*--temp*] [*--print*] ['option'] ['value']+
+Syntax: +:set [*--temp*] [*--print*] [*--pattern* 'pattern'] ['option'] ['value']+
Set an option.
@@ -1123,6 +1133,7 @@ If the option name ends with '?', the value of the option is shown instead. Usin
==== optional arguments
* +*-t*+, +*--temp*+: Set value temporarily until qutebrowser is closed.
* +*-p*+, +*--print*+: Print the value after setting.
+* +*-u*+, +*--pattern*+: The URL pattern to use.
[[set-cmd-text]]
=== set-cmd-text
@@ -1313,7 +1324,8 @@ Syntax: +:unbind [*--mode* 'mode'] 'key'+
Unbind a keychain.
==== positional arguments
-* +'key'+: The keychain or special key (inside <...>) to unbind.
+* +'key'+: The keychain to unbind. See the help for `:bind` for the correct syntax for keychains.
+
==== optional arguments
* +*-m*+, +*--mode*+: A mode to unbind the key in (default: `normal`). See `:help bindings.commands` for the available modes.
@@ -1494,13 +1506,16 @@ Drop selection and keep selection mode enabled.
[[follow-hint]]
=== follow-hint
-Syntax: +:follow-hint ['keystring']+
+Syntax: +:follow-hint [*--select*] ['keystring']+
Follow a hint.
==== positional arguments
* +'keystring'+: The hint to follow.
+==== optional arguments
+* +*-s*+, +*--select*+: Only select the given hint, don't necessarily follow it.
+
[[leave-mode]]
=== leave-mode
Leave the mode we're currently in.
diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc
index bb43e1dfe..266315d56 100644
--- a/doc/help/configuring.asciidoc
+++ b/doc/help/configuring.asciidoc
@@ -63,6 +63,10 @@ customizable.
Using the link:commands.html#set[`:set`] command and command completion, you
can quickly set settings interactively, for example `:set tabs.position left`.
+Some settings are also customizable for a given
+https://developer.chrome.com/apps/match_patterns[URL pattern] by doing e.g.
+`:set --pattern=*://example.com/ content.images false`.
+
To get more help about a setting, use e.g. `:help tabs.position`.
To bind and unbind keys, you can use the link:commands.html#bind[`:bind`] and
@@ -147,7 +151,6 @@ prefix to preserve backslashes) or a Python regex object:
If you want to read a setting, you can use the `c` object to do so as well:
`c.colors.tabs.even.bg = c.colors.tabs.odd.bg`.
-
Using strings for setting names
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -171,6 +174,26 @@ To read a setting, use the `config.get` method:
color = config.get('colors.completion.fg')
----
+Per-domain settings
+~~~~~~~~~~~~~~~~~~~
+
+Using `config.set`, some settings are also customizable for a given
+https://developer.chrome.com/apps/match_patterns[URL pattern]:
+
+[source,python]
+----
+config.set('content.images', False, '*://example.com/')
+----
+
+Alternatively, you can use `with config.pattern(...) as p:` to get a shortcut
+similar to `c.` which is scoped to the given domain:
+
+[source,python]
+----
+with config.pattern('*://example.com/') as p:
+ p.content.images = False
+----
+
Binding keys
~~~~~~~~~~~~
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index 3826e6e84..a428d65bd 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -201,6 +201,7 @@
|<<hints.uppercase,hints.uppercase>>|Make characters in hint strings uppercase.
|<<history_gap_interval,history_gap_interval>>|Maximum time (in minutes) between two history items for them to be considered being from the same browsing session.
|<<input.forward_unbound_keys,input.forward_unbound_keys>>|Which unbound keys to forward to the webview in normal mode.
+|<<input.insert_mode.auto_enter,input.insert_mode.auto_enter>>|Enter insert mode if an editable element is clicked.
|<<input.insert_mode.auto_leave,input.insert_mode.auto_leave>>|Leave insert mode if a non-editable element is clicked.
|<<input.insert_mode.auto_load,input.insert_mode.auto_load>>|Automatically enter insert mode if an editable element is focused after loading the page.
|<<input.insert_mode.plugins,input.insert_mode.plugins>>|Switch to insert mode when clicking flash and other plugins.
@@ -322,7 +323,7 @@ While it's possible to add bindings with this setting, it's recommended to use `
This setting is a dictionary containing mode names and dictionaries mapping keys to commands:
`{mode: {key: command}}`
If you want to map a key to another key, check the `bindings.key_mappings` setting instead.
-For special keys (can't be part of a keychain), enclose them in `<`...`>`. For modifiers, you can use either `-` or `+` as delimiters, and these names:
+For modifiers, you can use either `-` or `+` as delimiters, and these names:
* Control: `Control`, `Ctrl`
@@ -358,11 +359,8 @@ The following modes are available:
* prompt: Entered when there's a prompt to display, like for download
locations or when invoked from JavaScript.
- +
- You can bind normal keys in this mode, but they will be only active when
- a yes/no-prompt is asked. For other prompt modes, you can only bind
- special keys.
+* yesno: Entered when there's a yes/no prompt displayed.
* caret: Entered when pressing the `v` mode, used to select text using the
keyboard.
@@ -379,6 +377,8 @@ Default keybindings. If you want to add bindings, modify `bindings.commands` ins
The main purpose of this setting is that you can set it to an empty dictionary if you want to load no default keybindings at all.
If you want to preserve default bindings (and get new bindings when there is an update), use `config.bind()` in `config.py` or the `:bind` command, and leave this setting alone.
+This setting can only be set in config.py.
+
Type: <<types,Dict>>
Default:
@@ -582,8 +582,20 @@ Default:
* +pass:[sk]+: +pass:[set-cmd-text -s :bind]+
* +pass:[sl]+: +pass:[set-cmd-text -s :set -t]+
* +pass:[ss]+: +pass:[set-cmd-text -s :set]+
+* +pass:[tPH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.plugins ;; reload]+
+* +pass:[tPh]+: +pass:[config-cycle -p -u *://{url:host}/* content.plugins ;; reload]+
+* +pass:[tPu]+: +pass:[config-cycle -p -u {url} content.plugins ;; reload]+
+* +pass:[tSH]+: +pass:[config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload]+
+* +pass:[tSh]+: +pass:[config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload]+
+* +pass:[tSu]+: +pass:[config-cycle -p -u {url} content.javascript.enabled ;; reload]+
* +pass:[th]+: +pass:[back -t]+
* +pass:[tl]+: +pass:[forward -t]+
+* +pass:[tpH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.plugins ;; reload]+
+* +pass:[tph]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.plugins ;; reload]+
+* +pass:[tpu]+: +pass:[config-cycle -p -t -u {url} content.plugins ;; reload]+
+* +pass:[tsH]+: +pass:[config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload]+
+* +pass:[tsh]+: +pass:[config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload]+
+* +pass:[tsu]+: +pass:[config-cycle -p -t -u {url} content.javascript.enabled ;; reload]+
* +pass:[u]+: +pass:[undo]+
* +pass:[v]+: +pass:[enter-mode caret]+
* +pass:[wB]+: +pass:[set-cmd-text -s :bookmark-load -w]+
@@ -636,11 +648,17 @@ Default:
* +pass:[&lt;Shift-Tab&gt;]+: +pass:[prompt-item-focus prev]+
* +pass:[&lt;Tab&gt;]+: +pass:[prompt-item-focus next]+
* +pass:[&lt;Up&gt;]+: +pass:[prompt-item-focus prev]+
-* +pass:[n]+: +pass:[prompt-accept no]+
-* +pass:[y]+: +pass:[prompt-accept yes]+
- +pass:[register]+:
* +pass:[&lt;Escape&gt;]+: +pass:[leave-mode]+
+- +pass:[yesno]+:
+
+* +pass:[&lt;Alt-Shift-Y&gt;]+: +pass:[prompt-yank --sel]+
+* +pass:[&lt;Alt-Y&gt;]+: +pass:[prompt-yank]+
+* +pass:[&lt;Escape&gt;]+: +pass:[leave-mode]+
+* +pass:[&lt;Return&gt;]+: +pass:[prompt-accept]+
+* +pass:[n]+: +pass:[prompt-accept no]+
+* +pass:[y]+: +pass:[prompt-accept yes]+
[[bindings.key_mappings]]
=== bindings.key_mappings
@@ -1447,6 +1465,8 @@ Default:
Enable support for the HTML 5 web application cache feature.
An application cache acts like an HTTP cache in some sense. For documents that use the application cache via JavaScript, the loader engine will first ask the application cache for the contents, before hitting the network.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1524,6 +1544,8 @@ This setting is only available with the QtWebKit backend.
=== content.dns_prefetch
Try to pre-fetch DNS entries to speed up browsing.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1535,6 +1557,8 @@ This setting is only available with the QtWebKit backend.
Expand each subframe to its contents.
This will flatten all the frames to become one scrollable page.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1651,6 +1675,8 @@ Default:
=== content.hyperlink_auditing
Enable hyperlink auditing (`<a ping>`).
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1659,6 +1685,8 @@ Default: +pass:[false]+
=== content.images
Load images automatically in web pages.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1676,6 +1704,8 @@ Default: +pass:[true]+
Allow JavaScript to read from or write to the clipboard.
With QtWebEngine, writing the clipboard as response to a user interaction is always allowed.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1684,6 +1714,8 @@ Default: +pass:[false]+
=== content.javascript.can_close_tabs
Allow JavaScript to close tabs.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1694,6 +1726,8 @@ This setting is only available with the QtWebKit backend.
=== content.javascript.can_open_tabs_automatically
Allow JavaScript to open new tabs without user interaction.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1702,6 +1736,8 @@ Default: +pass:[false]+
=== content.javascript.enabled
Enable JavaScript.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1741,6 +1777,8 @@ Default: +pass:[true]+
=== content.local_content_can_access_file_urls
Allow locally loaded documents to access other local URLs.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1749,6 +1787,8 @@ Default: +pass:[true]+
=== content.local_content_can_access_remote_urls
Allow locally loaded documents to access remote URLs.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1757,6 +1797,8 @@ Default: +pass:[false]+
=== content.local_storage
Enable support for HTML 5 local storage and Web SQL.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1817,6 +1859,8 @@ This setting is only available with the QtWebKit backend.
=== content.plugins
Enable plugins in Web pages.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -1825,6 +1869,8 @@ Default: +pass:[false]+
=== content.print_element_backgrounds
Draw the background color and images also when the page is printed.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1889,6 +1935,8 @@ Default: empty
=== content.webgl
Enable WebGL.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -1906,6 +1954,8 @@ Default: +pass:[false]+
Monitor load requests for cross-site scripting attempts.
Suspicious scripts will be blocked and reported in the inspector's JavaScript console. Enabling this feature might have an impact on performance.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -2351,6 +2401,14 @@ Valid values:
Default: +pass:[auto]+
+[[input.insert_mode.auto_enter]]
+=== input.insert_mode.auto_enter
+Enter insert mode if an editable element is clicked.
+
+Type: <<types,Bool>>
+
+Default: +pass:[true]+
+
[[input.insert_mode.auto_leave]]
=== input.insert_mode.auto_leave
Leave insert mode if a non-editable element is clicked.
@@ -2379,6 +2437,8 @@ Default: +pass:[false]+
=== input.links_included_in_focus_chain
Include hyperlinks in the keyboard focus chain when tabbing.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[true]+
@@ -2406,6 +2466,8 @@ Default: +pass:[false]+
Enable spatial navigation.
Spatial navigation consists in the ability to navigate between focusable elements in a Web page, such as hyperlinks and form controls, by using Left, Right, Up and Down arrow keys. For example, if the user presses the Right key, heuristics determine whether there is an element he might be trying to reach towards the right and which element he probably wants.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -2550,6 +2612,8 @@ Default: +pass:[false]+
Enable smooth scrolling for web pages.
Note smooth scrolling does not work with the `:scroll-px` command.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
@@ -3137,6 +3201,8 @@ Default: +pass:[512]+
=== zoom.text_only
Apply the zoom factor on a frame only to the text or to all content.
+This setting supports URL patterns.
+
Type: <<types,Bool>>
Default: +pass:[false]+
diff --git a/doc/img/cheatsheet-big.png b/doc/img/cheatsheet-big.png
index e9386d121..57a0c4448 100644
--- a/doc/img/cheatsheet-big.png
+++ b/doc/img/cheatsheet-big.png
Binary files differ
diff --git a/doc/img/cheatsheet-small.png b/doc/img/cheatsheet-small.png
index 722165e0d..461c04c8c 100644
--- a/doc/img/cheatsheet-small.png
+++ b/doc/img/cheatsheet-small.png
Binary files differ
diff --git a/doc/install.asciidoc b/doc/install.asciidoc
index 4b1fd3f26..c48fc71d6 100644
--- a/doc/install.asciidoc
+++ b/doc/install.asciidoc
@@ -35,17 +35,21 @@ Ubuntu 16.04 doesn't come with an up-to-date engine (a new enough QtWebKit, or
QtWebEngine). However, it comes with Python 3.5, so you can
<<tox,install qutebrowser via tox>>.
+You'll need some basic libraries to use the tox-installed PyQt:
+
+----
+# apt install libglib2.0-0 libgl1 libfontconfig1 libx11-xcb1 libxi6 libxrender1 libdbus-1-3
+----
+
Debian Stretch / Ubuntu 17.04 and 17.10
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Those versions come with QtWebEngine in the repositories. This makes it possible
to install qutebrowser via the Debian package.
-Get the qutebrowser package from the
-https://github.com/qutebrowser/qutebrowser/releases[release page] and download
-the https://qutebrowser.org/python3-pypeg2_2.15.2-1_all.deb[PyPEG2 package].
-
-(If you are using debian testing you can just use the python3-pypeg2 package from the repos)
+Download the https://packages.debian.org/sid/all/qutebrowser/download[qutebrowser] and
+https://packages.debian.org/sid/all/python3-pypeg2/download[PyPEG2]
+package from the Debian repositories.
Install the packages:
@@ -277,6 +281,11 @@ PS C:\> Install-Package qutebrowser
----
C:\> choco install qutebrowser
----
+* Scoop's client
+----
+C:\> scoop bucket add extras
+C:\> scoop install qutebrowser
+----
Manual install
~~~~~~~~~~~~~~
diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc
index 988235f87..8d8f21aa0 100644
--- a/doc/quickstart.asciidoc
+++ b/doc/quickstart.asciidoc
@@ -22,9 +22,9 @@ Basic keybindings to get you started
What to do now
--------------
-* View the link:https://qutebrowser.org/img/cheatsheet-big.png[key binding cheatsheet]
+* View the link:https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png[key binding cheatsheet]
to make yourself familiar with the key bindings: +
-image:https://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="https://qutebrowser.org/img/cheatsheet-big.png"]
+image:https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png"]
* There's also a https://www.shortcutfoo.com/app/dojos/qutebrowser[free training
course] on shortcutfoo for the keybindings - note that you need to be in
insert mode (i) for it to work.
diff --git a/misc/cheatsheet.svg b/misc/cheatsheet.svg
index 68e126e17..bb87142a4 100644
--- a/misc/cheatsheet.svg
+++ b/misc/cheatsheet.svg
@@ -32,22 +32,24 @@
objecttolerance="10"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
- inkscape:zoom="1.24"
- inkscape:cx="305.29152"
- inkscape:cy="465.48793"
+ inkscape:zoom="1.7536248"
+ inkscape:cx="430.72917"
+ inkscape:cy="268.64059"
inkscape:document-units="px"
inkscape:current-layer="layer1"
width="1024px"
height="640px"
showgrid="false"
- inkscape:window-width="1024"
- inkscape:window-height="723"
+ inkscape:window-width="2560"
+ inkscape:window-height="1440"
inkscape:window-x="0"
inkscape:window-y="0"
showguides="true"
inkscape:guide-bbox="true"
- inkscape:window-maximized="1"
- inkscape:snap-text-baseline="true">
+ inkscape:window-maximized="0"
+ inkscape:snap-text-baseline="true"
+ inkscape:measure-start="0,0"
+ inkscape:measure-end="0,0">
<inkscape:grid
id="GridFromPre046Settings"
type="xygrid"
@@ -2688,7 +2690,8 @@
id="flowPara5711"> </flowPara></flowRoot> <flowRoot
xml:space="preserve"
id="flowRoot5691-0"
- style="font-style:normal;font-weight:normal;font-size:12.80000019px;line-height:0.01%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.06666672"><flowRegion
+ style="font-style:normal;font-weight:normal;font-size:12.80000019px;line-height:0.01%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.06666672"
+ transform="translate(0,-10)"><flowRegion
id="flowRegion5693-7"
style="font-family:sans-serif;stroke-width:1.06666672"><rect
id="rect5695-0"
@@ -3660,5 +3663,64 @@
sodipodi:role="line"
style="font-size:8.53333378px;line-height:0.89999998;stroke-width:1.06666672"
id="tspan6220">items</tspan></text>
- </g>
+ <text
+ xml:space="preserve"
+ style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:12.80000019px;line-height:0%;font-family:TlwgTypewriter;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.06666672"
+ x="417.29486"
+ y="205.18887"
+ id="text7245-1-3"><tspan
+ sodipodi:role="line"
+ x="417.29486"
+ y="205.18887"
+ id="tspan7366-3-6"
+ style="font-size:9.60000038px;line-height:0.89999998;stroke-width:1.06666672"> </tspan><tspan
+ sodipodi:role="line"
+ x="417.29486"
+ y="213.07179"
+ id="tspan5293-53"
+ style="font-size:8.53333378px;line-height:0.89999998;stroke-width:1.06666672">toggle</tspan><tspan
+ sodipodi:role="line"
+ x="417.29486"
+ y="220.75179"
+ style="font-size:8.53333378px;line-height:0.89999998;stroke-width:1.06666672;fill:#ff0000"
+ id="tspan6091">(12)</tspan><tspan
+ sodipodi:role="line"
+ x="417.29486"
+ y="225.70012"
+ style="font-size:8.53333378px;line-height:0.89999998;stroke-width:1.06666672"
+ id="tspan6087" /><tspan
+ sodipodi:role="line"
+ x="417.29486"
+ y="225.70012"
+ style="font-size:8.53333378px;line-height:0.89999998;stroke-width:1.06666672"
+ id="tspan6089" /></text>
+ <flowRoot
+ transform="translate(-1.2953814,90.2721)"
+ xml:space="preserve"
+ id="flowRoot5691-0-5"
+ style="font-style:normal;font-weight:normal;font-size:12.80000019px;line-height:0.01%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.06666672"><flowRegion
+ id="flowRegion5693-7-6"
+ style="font-family:sans-serif;stroke-width:1.06666672"><rect
+ id="rect5695-0-2"
+ width="344"
+ height="173.33333"
+ x="19.42783"
+ y="520.07886"
+ style="font-family:sans-serif;fill:#000000;stroke-width:1.13777781" /></flowRegion><flowPara
+ style="font-weight:bold;font-size:10.66666698px;line-height:1.25;font-family:sans-serif;-inkscape-font-specification:'Sans Bold';fill:#000000;stroke-width:1.06666672"
+ id="flowPara5701-9-2"><flowSpan
+ style="font-weight:bold;font-family:sans-serif;-inkscape-font-specification:'Sans Bold';fill:#ff0000;stroke-width:1.06666672"
+ id="flowSpan5705-5-1">(12)</flowSpan> toggling settings:</flowPara><flowPara
+ style="font-size:10.66666698px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06666672"
+ id="flowPara6196">tsh - toggle scripts for the current host (temporarily)</flowPara><flowPara
+ style="font-size:10.66666698px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06666672"
+ id="flowPara6200">tSh - like <flowSpan
+ style="font-style:italic"
+ id="flowSpan6202">tsh</flowSpan>, but permanently</flowPara><flowPara
+ style="font-size:10.66666698px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06666672"
+ id="flowPara6206">tsH/tsu - like <flowSpan
+ style="font-style:italic"
+ id="flowSpan6210">tsh</flowSpan>, but including subdomains / with exact URL</flowPara><flowPara
+ style="font-size:10.66666698px;line-height:1.25;font-family:sans-serif;fill:#000000;stroke-width:1.06666672"
+ id="flowPara6208">tph - toggle plugins</flowPara></flowRoot> </g>
</svg>
diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt
index df27fc428..aa5de8b34 100644
--- a/misc/requirements/requirements-codecov.txt
+++ b/misc/requirements/requirements-codecov.txt
@@ -3,7 +3,7 @@
certifi==2018.1.18
chardet==3.0.4
codecov==2.0.15
-coverage==4.5
+coverage==4.5.1
idna==2.6
requests==2.18.4
urllib3==1.22
diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt
index 5a43e66d1..f467fa474 100644
--- a/misc/requirements/requirements-flake8.txt
+++ b/misc/requirements/requirements-flake8.txt
@@ -6,12 +6,12 @@ flake8-bugbear==18.2.0
flake8-builtins==1.0.post0
flake8-comprehensions==1.4.1
flake8-copyright==0.2.0
-flake8-debugger==3.0.0
+flake8-debugger==3.1.0
flake8-deprecated==1.3
flake8-docstrings==1.3.0
flake8-future-import==0.4.4
flake8-mock==0.3
-flake8-per-file-ignores==0.4
+flake8-per-file-ignores==0.5
flake8-polyfill==1.0.2
flake8-string-format==0.2.3
flake8-tidy-imports==1.1.0
diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt
index bd9654b1b..4784f27df 100644
--- a/misc/requirements/requirements-pip.txt
+++ b/misc/requirements/requirements-pip.txt
@@ -1,8 +1,8 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
appdirs==1.4.3
-packaging==16.8
+packaging==17.1
pyparsing==2.2.0
-setuptools==38.5.0
+setuptools==38.5.2
six==1.11.0
wheel==0.30.0
diff --git a/misc/requirements/requirements-pylint-master.txt b/misc/requirements/requirements-pylint-master.txt
index d23d2ea57..6364f0fcf 100644
--- a/misc/requirements/requirements-pylint-master.txt
+++ b/misc/requirements/requirements-pylint-master.txt
@@ -5,7 +5,7 @@ certifi==2018.1.18
chardet==3.0.4
github3.py==0.9.6
idna==2.6
-isort==4.3.2
+isort==4.3.4
lazy-object-proxy==1.3.1
mccabe==0.6.1
-e git+https://github.com/PyCQA/pylint.git#egg=pylint
diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt
index e86be34e2..de27257e1 100644
--- a/misc/requirements/requirements-pylint.txt
+++ b/misc/requirements/requirements-pylint.txt
@@ -5,7 +5,7 @@ certifi==2018.1.18
chardet==3.0.4
github3.py==0.9.6
idna==2.6
-isort==4.3.2
+isort==4.3.4
lazy-object-proxy==1.3.1
mccabe==0.6.1
pylint==1.8.2
diff --git a/misc/requirements/requirements-pyqt-old.txt b/misc/requirements/requirements-pyqt-old.txt
new file mode 100644
index 000000000..f0fa50ad5
--- /dev/null
+++ b/misc/requirements/requirements-pyqt-old.txt
@@ -0,0 +1,4 @@
+# This file is automatically generated by scripts/dev/recompile_requirements.py
+
+PyQt5==5.10 # rq.filter: != 5.10.1
+sip==4.19.8
diff --git a/misc/requirements/requirements-pyqt-old.txt-raw b/misc/requirements/requirements-pyqt-old.txt-raw
new file mode 100644
index 000000000..16b82e0d5
--- /dev/null
+++ b/misc/requirements/requirements-pyqt-old.txt-raw
@@ -0,0 +1,2 @@
+PyQt5==5.10.0
+#@ filter: PyQt5 != 5.10.1 \ No newline at end of file
diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt
index 99ba1b7cc..059ff2df7 100644
--- a/misc/requirements/requirements-pyqt.txt
+++ b/misc/requirements/requirements-pyqt.txt
@@ -1,4 +1,4 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-PyQt5==5.10
-sip==4.19.7
+PyQt5==5.10.1
+sip==4.19.8
diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt
index eebd2945b..409ad4e33 100644
--- a/misc/requirements/requirements-tests.txt
+++ b/misc/requirements/requirements-tests.txt
@@ -5,13 +5,13 @@ beautifulsoup4==4.6.0
cheroot==6.0.0
click==6.7
# colorama==0.3.9
-coverage==4.5
+coverage==4.5.1
EasyProcess==0.2.3
fields==5.0.0
Flask==0.12.2
glob2==0.6
hunter==2.0.2
-hypothesis==3.44.25
+hypothesis==3.49.0
itsdangerous==0.24
# Jinja2==2.10
Mako==1.0.7
@@ -22,18 +22,18 @@ parse-type==0.4.2
pluggy==0.6.0
py==1.5.2
py-cpuinfo==3.3.0
-pytest==3.4.0
+pytest==3.4.2
pytest-bdd==2.20.0
pytest-benchmark==3.1.1
pytest-cov==2.5.1
-pytest-faulthandler==1.3.1
+pytest-faulthandler==1.4.1
pytest-instafail==0.3.0
-pytest-mock==1.6.3
+pytest-mock==1.7.1
pytest-qt==2.3.1
pytest-repeat==0.4.1
pytest-rerunfailures==4.0
pytest-travis-fold==1.3.0
-pytest-xvfb==1.0.0
+pytest-xvfb==1.1.0
PyVirtualDisplay==0.2.1
six==1.11.0
vulture==0.26
diff --git a/misc/userscripts/open_download b/misc/userscripts/open_download
index ecc1d7209..8dbb11384 100755
--- a/misc/userscripts/open_download
+++ b/misc/userscripts/open_download
@@ -52,7 +52,7 @@ die() {
if ! [ -d "$DOWNLOAD_DIR" ] ; then
die "Download directory »$DOWNLOAD_DIR« not found!"
fi
-if ! which "${ROFI_CMD}" > /dev/null ; then
+if ! command -v "${ROFI_CMD}" > /dev/null ; then
die "Rofi command »${ROFI_CMD}« not found in PATH!"
fi
diff --git a/misc/userscripts/password_fill b/misc/userscripts/password_fill
index 8dba68c2b..a61a42c68 100755
--- a/misc/userscripts/password_fill
+++ b/misc/userscripts/password_fill
@@ -220,7 +220,7 @@ user_pattern='^(user|username|login): '
GPG_OPTS=( "--quiet" "--yes" "--compress-algo=none" "--no-encrypt-to" )
GPG="gpg"
export GPG_TTY="${GPG_TTY:-$(tty 2>/dev/null)}"
-which gpg2 &>/dev/null && GPG="gpg2"
+command -v gpg2 &>/dev/null && GPG="gpg2"
[[ -n $GPG_AGENT_INFO || $GPG == "gpg2" ]] && GPG_OPTS+=( "--batch" "--use-agent" )
pass_backend() {
diff --git a/misc/userscripts/readability b/misc/userscripts/readability
index a5425dbac..d0ef43795 100755
--- a/misc/userscripts/readability
+++ b/misc/userscripts/readability
@@ -13,7 +13,11 @@
from __future__ import absolute_import
import codecs, os
-tmpfile=os.path.expanduser('~/.local/share/qutebrowser/userscripts/readability.html')
+tmpfile = os.path.join(
+ os.environ.get('QUTE_DATA_DIR',
+ os.path.expanduser('~/.local/share/qutebrowser')),
+ 'userscripts/readability.html')
+
if not os.path.exists(os.path.dirname(tmpfile)):
os.makedirs(os.path.dirname(tmpfile))
diff --git a/misc/userscripts/tor_identity b/misc/userscripts/tor_identity
new file mode 100755
index 000000000..93b6d4136
--- /dev/null
+++ b/misc/userscripts/tor_identity
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+# Copyright 2018 jnphilipp <mail@jnphilipp.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+# Change your tor identity.
+#
+# Set a hotkey to launch this script, then:
+# :bind ti spawn --userscript tor_identity PASSWORD
+#
+# Use the hotkey to change your tor identity, press 'ti' to change it.
+# https://stem.torproject.org/faq.html#how-do-i-request-a-new-identity-from-tor
+#
+
+import os
+import sys
+
+try:
+ from stem import Signal
+ from stem.control import Controller
+except ImportError:
+ if os.getenv('QUTE_FIFO'):
+ with open(os.environ['QUTE_FIFO'], 'w') as f:
+ f.write('message-error "Failed to import stem."')
+ else:
+ print('Failed to import stem.')
+
+
+password = sys.argv[1]
+with Controller.from_port(port=9051) as controller:
+ controller.authenticate(password)
+ controller.signal(Signal.NEWNYM)
+ if os.getenv('QUTE_FIFO'):
+ with open(os.environ['QUTE_FIFO'], 'w') as f:
+ f.write('message-info "Tor identity changed."')
+ else:
+ print('Tor identity changed.')
diff --git a/pytest.ini b/pytest.ini
index 89571aebc..1a3f625e9 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -27,6 +27,7 @@ markers =
no_invalid_lines: Don't fail on unparseable lines in end2end tests
issue2478: Tests which are broken on Windows with QtWebEngine, https://github.com/qutebrowser/qutebrowser/issues/2478
issue3572: Tests which are broken with QtWebEngine and Qt 5.10, https://github.com/qutebrowser/qutebrowser/issues/3572
+ qtbug60673: Tests which are broken if the conversion from orange selection to real selection is flaky
fake_os: Fake utils.is_* to a fake operating system
unicode_locale: Tests which need an unicode locale to work
qt_log_level_fail = WARNING
diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py
index 3da270437..31fd5983f 100644
--- a/qutebrowser/__init__.py
+++ b/qutebrowser/__init__.py
@@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2018 Florian Bruhin (The Compiler)"
__license__ = "GPL"
__maintainer__ = __author__
__email__ = "mail@qutebrowser.org"
-__version_info__ = (1, 1, 1)
+__version_info__ = (1, 2, 1)
__version__ = '.'.join(str(e) for e in __version_info__)
__description__ = "A keyboard-driven, vim-like browser based on PyQt5."
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
index ec477ce8f..3cce2e85e 100644
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -95,6 +95,7 @@ def run(args):
log.init.debug("Initializing directories...")
standarddir.init(args)
+ utils.preload_resources()
log.init.debug("Initializing config...")
configinit.early_init(args)
@@ -339,7 +340,7 @@ def _open_startpage(win_id=None):
for cur_win_id in list(window_ids): # Copying as the dict could change
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=cur_win_id)
- if tabbed_browser.count() == 0:
+ if tabbed_browser.widget.count() == 0:
log.init.debug("Opening start pages")
for url in config.val.url.start_pages:
tabbed_browser.tabopen(url)
@@ -772,6 +773,8 @@ class Quitter:
pre_text="Error while saving {}".format(key))
# Disable storage so removing tempdir will work
websettings.shutdown()
+ # Disable application proxy factory to fix segfaults with Qt 5.10.1
+ proxy.shutdown()
# Re-enable faulthandler to stdout, then remove crash log
log.destroy.debug("Deactivating crash log...")
objreg.get('crash-handler').destroy_crashlogfile()
@@ -840,7 +843,11 @@ class Application(QApplication):
def event(self, e):
"""Handle macOS FileOpen events."""
if e.type() == QEvent.FileOpen:
- open_url(e.url(), no_raise=True)
+ url = e.url()
+ if url.isValid():
+ open_url(url, no_raise=True)
+ else:
+ message.error("Invalid URL: {}".format(url.errorString()))
else:
return super().event(e)
@@ -878,6 +885,7 @@ class EventFilter(QObject):
self._handlers = {
QEvent.KeyPress: self._handle_key_event,
QEvent.KeyRelease: self._handle_key_event,
+ QEvent.ShortcutOverride: self._handle_key_event,
}
def _handle_key_event(self, event):
@@ -895,7 +903,7 @@ class EventFilter(QObject):
return False
try:
man = objreg.get('mode-manager', scope='window', window='current')
- return man.eventFilter(event)
+ return man.handle_event(event)
except objreg.RegistryUnavailableError:
# No window available yet, or not a MainWindow
return False
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 6ed5afe52..d3345c88b 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -30,7 +30,8 @@ from PyQt5.QtWidgets import QWidget, QApplication
from qutebrowser.keyinput import modeman
from qutebrowser.config import config
-from qutebrowser.utils import utils, objreg, usertypes, log, qtutils
+from qutebrowser.utils import (utils, objreg, usertypes, log, qtutils,
+ urlutils, message)
from qutebrowser.misc import miscwidgets, objects
from qutebrowser.browser import mouse, hints
@@ -94,6 +95,8 @@ class TabData:
keep_icon: Whether the (e.g. cloned) icon should not be cleared on page
load.
inspector: The QWebInspector used for this webview.
+ open_target: Where to open the next link.
+ Only used for QtWebKit.
override_target: Override for open_target for fake clicks (like hints).
Only used for QtWebKit.
pinned: Flag to pin the tab.
@@ -104,6 +107,7 @@ class TabData:
keep_icon = attr.ib(False)
inspector = attr.ib(None)
+ open_target = attr.ib(usertypes.ClickTarget.normal)
override_target = attr.ib(None)
pinned = attr.ib(False)
fullscreen = attr.ib(False)
@@ -342,7 +346,7 @@ class AbstractCaret(QObject):
def _on_mode_entered(self, mode):
raise NotImplementedError
- def _on_mode_left(self):
+ def _on_mode_left(self, mode):
raise NotImplementedError
def move_to_next_line(self, count=1):
@@ -612,6 +616,7 @@ class AbstractTab(QWidget):
process terminated.
arg 0: A TerminationStatus member.
arg 1: The exit code.
+ predicted_navigation: Emitted before we tell Qt to open a URL.
"""
window_close_requested = pyqtSignal()
@@ -629,6 +634,7 @@ class AbstractTab(QWidget):
add_history_item = pyqtSignal(QUrl, QUrl, str) # url, requested url, title
fullscreen_requested = pyqtSignal(bool)
renderer_process_terminated = pyqtSignal(TerminationStatus, int)
+ predicted_navigation = pyqtSignal(QUrl)
def __init__(self, *, win_id, mode_manager, private, parent=None):
self.private = private
@@ -659,6 +665,8 @@ class AbstractTab(QWidget):
objreg.register('hintmanager', hintmanager, scope='tab',
window=self.win_id, tab=self.tab_id)
+ self.predicted_navigation.connect(self._on_predicted_navigation)
+
def _set_widget(self, widget):
# pylint: disable=protected-access
self._widget = widget
@@ -671,6 +679,7 @@ class AbstractTab(QWidget):
self.printing._widget = widget
self.action._widget = widget
self.elements._widget = widget
+ self.settings._settings = widget.settings()
self._install_event_filter()
self.zoom.set_default()
@@ -706,6 +715,14 @@ class AbstractTab(QWidget):
QApplication.postEvent(recipient, evt)
@pyqtSlot(QUrl)
+ def _on_predicted_navigation(self, url):
+ """Adjust the title if we are going to visit an URL soon."""
+ qtutils.ensure_valid(url)
+ url_string = url.toDisplayString()
+ log.webview.debug("Predicted navigation: {}".format(url_string))
+ self.title_changed.emit(url_string)
+
+ @pyqtSlot(QUrl)
def _on_url_changed(self, url):
"""Update title when URL has changed and no title is available."""
if url.isValid() and not self.title():
@@ -719,6 +736,23 @@ class AbstractTab(QWidget):
self._set_load_status(usertypes.LoadStatus.loading)
self.load_started.emit()
+ @pyqtSlot(usertypes.NavigationRequest)
+ def _on_navigation_request(self, navigation):
+ """Handle common acceptNavigationRequest code."""
+ url = utils.elide(navigation.url.toDisplayString(), 100)
+ log.webview.debug("navigation request: url {}, type {}, is_main_frame "
+ "{}".format(url,
+ navigation.navigation_type,
+ navigation.is_main_frame))
+
+ if (navigation.navigation_type == navigation.Type.link_clicked and
+ not navigation.url.isValid()):
+ msg = urlutils.get_errstring(navigation.url,
+ "Invalid link clicked")
+ message.error(msg)
+ self.data.open_target = usertypes.ClickTarget.normal
+ navigation.accepted = False
+
def handle_auto_insert_mode(self, ok):
"""Handle `input.insert_mode.auto_load` after loading finished."""
if not config.val.input.insert_mode.auto_load or not ok:
@@ -788,11 +822,12 @@ class AbstractTab(QWidget):
def load_status(self):
return self._load_status
- def _openurl_prepare(self, url):
+ def _openurl_prepare(self, url, *, predict=True):
qtutils.ensure_valid(url)
- self.title_changed.emit(url.toDisplayString())
+ if predict:
+ self.predicted_navigation.emit(url)
- def openurl(self, url):
+ def openurl(self, url, *, predict=True):
raise NotImplementedError
def reload(self, *, force=False):
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index b56c3d6ae..a4786e5e0 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -27,14 +27,13 @@ import typing
from PyQt5.QtWidgets import QApplication, QTabBar, QDialog
from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery
-from PyQt5.QtGui import QKeyEvent
from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog
from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners
from qutebrowser.config import config, configdata
from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate,
webelem, downloads)
-from qutebrowser.keyinput import modeman
+from qutebrowser.keyinput import modeman, keyutils
from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
objreg, utils, standarddir)
from qutebrowser.utils.usertypes import KeyMode
@@ -54,7 +53,6 @@ class CommandDispatcher:
cmdutils.register() decorators are run, currentWidget() will return None.
Attributes:
- _editor: The ExternalEditor object.
_win_id: The window ID the CommandDispatcher is associated with.
_tabbed_browser: The TabbedBrowser used.
"""
@@ -74,16 +72,16 @@ class CommandDispatcher:
def _count(self):
"""Convenience method to get the widget count."""
- return self._tabbed_browser.count()
+ return self._tabbed_browser.widget.count()
def _set_current_index(self, idx):
"""Convenience method to set the current widget index."""
cmdutils.check_overflow(idx, 'int')
- self._tabbed_browser.setCurrentIndex(idx)
+ self._tabbed_browser.widget.setCurrentIndex(idx)
def _current_index(self):
"""Convenience method to get the current widget index."""
- return self._tabbed_browser.currentIndex()
+ return self._tabbed_browser.widget.currentIndex()
def _current_url(self):
"""Convenience method to get the current url."""
@@ -102,7 +100,7 @@ class CommandDispatcher:
def _current_widget(self):
"""Get the currently active widget from a command."""
- widget = self._tabbed_browser.currentWidget()
+ widget = self._tabbed_browser.widget.currentWidget()
if widget is None:
raise cmdexc.CommandError("No WebView available yet!")
return widget
@@ -148,10 +146,10 @@ class CommandDispatcher:
None if no widget was found.
"""
if count is None:
- return self._tabbed_browser.currentWidget()
+ return self._tabbed_browser.widget.currentWidget()
elif 1 <= count <= self._count():
cmdutils.check_overflow(count + 1, 'int')
- return self._tabbed_browser.widget(count - 1)
+ return self._tabbed_browser.widget.widget(count - 1)
else:
return None
@@ -164,7 +162,7 @@ class CommandDispatcher:
if not show_error:
return
raise cmdexc.CommandError("No last focused tab!")
- idx = self._tabbed_browser.indexOf(tab)
+ idx = self._tabbed_browser.widget.indexOf(tab)
if idx == -1:
raise cmdexc.CommandError("Last focused tab vanished!")
self._set_current_index(idx)
@@ -213,7 +211,7 @@ class CommandDispatcher:
what's configured in 'tabs.select_on_remove'.
count: The tab index to close, or None
"""
- tabbar = self._tabbed_browser.tabBar()
+ tabbar = self._tabbed_browser.widget.tabBar()
selection_override = self._get_selection_override(prev, next_,
opposite)
@@ -265,7 +263,7 @@ class CommandDispatcher:
return
to_pin = not tab.data.pinned
- self._tabbed_browser.set_tab_pinned(tab, to_pin)
+ self._tabbed_browser.widget.set_tab_pinned(tab, to_pin)
@cmdutils.register(instance='command-dispatcher', name='open',
maxsplit=0, scope='window')
@@ -484,7 +482,8 @@ class CommandDispatcher:
"""
cmdutils.check_exclusive((bg, window), 'bw')
curtab = self._current_widget()
- cur_title = self._tabbed_browser.page_title(self._current_index())
+ cur_title = self._tabbed_browser.widget.page_title(
+ self._current_index())
try:
history = curtab.history.serialize()
except browsertab.WebTabError as e:
@@ -500,18 +499,18 @@ class CommandDispatcher:
newtab = new_tabbed_browser.tabopen(background=bg)
new_tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=newtab.win_id)
- idx = new_tabbed_browser.indexOf(newtab)
+ idx = new_tabbed_browser.widget.indexOf(newtab)
- new_tabbed_browser.set_page_title(idx, cur_title)
+ new_tabbed_browser.widget.set_page_title(idx, cur_title)
if config.val.tabs.favicons.show:
- new_tabbed_browser.setTabIcon(idx, curtab.icon())
+ new_tabbed_browser.widget.setTabIcon(idx, curtab.icon())
if config.val.tabs.tabs_are_windows:
- new_tabbed_browser.window().setWindowIcon(curtab.icon())
+ new_tabbed_browser.widget.window().setWindowIcon(curtab.icon())
newtab.data.keep_icon = True
newtab.history.deserialize(history)
newtab.zoom.set_factor(curtab.zoom.factor())
- new_tabbed_browser.set_tab_pinned(newtab, curtab.data.pinned)
+ new_tabbed_browser.widget.set_tab_pinned(newtab, curtab.data.pinned)
return newtab
@cmdutils.register(instance='command-dispatcher', scope='window')
@@ -847,7 +846,7 @@ class CommandDispatcher:
keep: Stay in visual mode after yanking the selection.
"""
if what == 'title':
- s = self._tabbed_browser.page_title(self._current_index())
+ s = self._tabbed_browser.widget.page_title(self._current_index())
elif what == 'domain':
port = self._current_url().port()
s = '{}://{}{}'.format(self._current_url().scheme(),
@@ -959,7 +958,7 @@ class CommandDispatcher:
force: Avoid confirmation for pinned tabs.
"""
cmdutils.check_exclusive((prev, next_), 'pn')
- cur_idx = self._tabbed_browser.currentIndex()
+ cur_idx = self._tabbed_browser.widget.currentIndex()
assert cur_idx != -1
def _to_close(i):
@@ -1076,11 +1075,11 @@ class CommandDispatcher:
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=win_id)
- if not 0 < idx <= tabbed_browser.count():
+ if not 0 < idx <= tabbed_browser.widget.count():
raise cmdexc.CommandError(
"There's no tab with index {}!".format(idx))
- return (tabbed_browser, tabbed_browser.widget(idx-1))
+ return (tabbed_browser, tabbed_browser.widget.widget(idx-1))
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
@@ -1092,24 +1091,26 @@ class CommandDispatcher:
Focuses window if necessary when index is given. If both index and
count are given, use count.
+ With neither index nor count given, open the qute://tabs page.
+
Args:
index: The [win_id/]index of the tab to focus. Or a substring
in which case the closest match will be focused.
count: The tab index to focus, starting with 1.
"""
if count is None and index is None:
- raise cmdexc.CommandError("buffer: Either a count or the argument "
- "index must be specified.")
+ self.openurl('qute://tabs/', tab=True)
+ return
if count is not None:
index = str(count)
tabbed_browser, tab = self._resolve_buffer_index(index)
- window = tabbed_browser.window()
+ window = tabbed_browser.widget.window()
window.activateWindow()
window.raise_()
- tabbed_browser.setCurrentWidget(tab)
+ tabbed_browser.widget.setCurrentWidget(tab)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('index', choices=['last'])
@@ -1193,7 +1194,7 @@ class CommandDispatcher:
cur_idx = self._current_index()
cmdutils.check_overflow(cur_idx, 'int')
cmdutils.check_overflow(new_idx, 'int')
- self._tabbed_browser.tabBar().moveTab(cur_idx, new_idx)
+ self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx)
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0, no_replace_variables=True)
@@ -1277,10 +1278,10 @@ class CommandDispatcher:
idx = self._current_index()
if idx != -1:
- env['QUTE_TITLE'] = self._tabbed_browser.page_title(idx)
+ env['QUTE_TITLE'] = self._tabbed_browser.widget.page_title(idx)
# FIXME:qtwebengine: If tab is None, run_async will fail!
- tab = self._tabbed_browser.currentWidget()
+ tab = self._tabbed_browser.widget.currentWidget()
try:
url = self._tabbed_browser.current_url()
@@ -1638,7 +1639,7 @@ class CommandDispatcher:
ed = editor.ExternalEditor(watch=True, parent=self._tabbed_browser)
ed.file_updated.connect(functools.partial(
- self.on_file_updated, elem))
+ self.on_file_updated, ed, elem))
ed.editing_finished.connect(lambda: mainwindow.raise_window(
objreg.last_focused_window(), alert=False))
ed.edit(text, caret_position)
@@ -1653,7 +1654,7 @@ class CommandDispatcher:
tab = self._current_widget()
tab.elements.find_focused(self._open_editor_cb)
- def on_file_updated(self, elem, text):
+ def on_file_updated(self, ed, elem, text):
"""Write the editor text into the form field and clean up tempfile.
Callback for GUIProcess when the edited text was updated.
@@ -1666,8 +1667,10 @@ class CommandDispatcher:
elem.set_value(text)
except webelem.OrphanedError as e:
message.error('Edited element vanished')
+ ed.backup()
except webelem.Error as e:
- raise cmdexc.CommandError(str(e))
+ message.error(str(e))
+ ed.backup()
@cmdutils.register(instance='command-dispatcher', maxsplit=0,
scope='window')
@@ -1776,10 +1779,10 @@ class CommandDispatcher:
"""
self.set_mark("'")
tab = self._current_widget()
- if tab.search.search_displayed:
- tab.search.clear()
if not text:
+ if tab.search.search_displayed:
+ tab.search.clear()
return
options = {
@@ -2110,15 +2113,13 @@ class CommandDispatcher:
global_: If given, the keys are sent to the qutebrowser UI.
"""
try:
- keyinfos = utils.parse_keystring(keystring)
- except utils.KeyParseError as e:
+ sequence = keyutils.KeySequence.parse(keystring)
+ except keyutils.KeyParseError as e:
raise cmdexc.CommandError(str(e))
- for keyinfo in keyinfos:
- press_event = QKeyEvent(QEvent.KeyPress, keyinfo.key,
- keyinfo.modifiers, keyinfo.text)
- release_event = QKeyEvent(QEvent.KeyRelease, keyinfo.key,
- keyinfo.modifiers, keyinfo.text)
+ for keyinfo in sequence:
+ press_event = keyinfo.to_event(QEvent.KeyPress)
+ release_event = keyinfo.to_event(QEvent.KeyRelease)
if global_:
window = QApplication.focusWindow()
@@ -2218,5 +2219,5 @@ class CommandDispatcher:
pass
return
- window = self._tabbed_browser.window()
+ window = self._tabbed_browser.widget.window()
window.setWindowState(window.windowState() ^ Qt.WindowFullScreen)
diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py
index 4f390b18b..dd112e00a 100644
--- a/qutebrowser/browser/downloads.py
+++ b/qutebrowser/browser/downloads.py
@@ -238,11 +238,14 @@ class FileDownloadTarget(_DownloadTarget):
Attributes:
filename: Filename where the download should be saved.
+ force_overwrite: Whether to overwrite the target without
+ prompting the user.
"""
- def __init__(self, filename):
+ def __init__(self, filename, force_overwrite=False):
# pylint: disable=super-init-not-called
self.filename = filename
+ self.force_overwrite = force_overwrite
def suggested_filename(self):
return os.path.basename(self.filename)
@@ -738,7 +741,8 @@ class AbstractDownloadItem(QObject):
if isinstance(target, FileObjDownloadTarget):
self._set_fileobj(target.fileobj, autoclose=False)
elif isinstance(target, FileDownloadTarget):
- self._set_filename(target.filename)
+ self._set_filename(
+ target.filename, force_overwrite=target.force_overwrite)
elif isinstance(target, OpenFileDownloadTarget):
try:
fobj = temp_download_manager.get_tmpfile(self.basename)
diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py
index fb064f6c1..6879f4cf6 100644
--- a/qutebrowser/browser/greasemonkey.py
+++ b/qutebrowser/browser/greasemonkey.py
@@ -23,13 +23,16 @@ import re
import os
import json
import fnmatch
+import functools
import glob
+import textwrap
import attr
from PyQt5.QtCore import pyqtSignal, QObject, QUrl
-from qutebrowser.utils import log, standarddir, jinja, objreg
+from qutebrowser.utils import log, standarddir, jinja, objreg, utils
from qutebrowser.commands import cmdutils
+from qutebrowser.browser import downloads
def _scripts_dir():
@@ -45,6 +48,7 @@ class GreasemonkeyScript:
self._code = code
self.includes = []
self.excludes = []
+ self.requires = []
self.description = None
self.name = None
self.namespace = None
@@ -66,6 +70,8 @@ class GreasemonkeyScript:
self.run_at = value
elif name == 'noframes':
self.runs_on_sub_frames = False
+ elif name == 'require':
+ self.requires.append(value)
HEADER_REGEX = r'// ==UserScript==|\n+// ==/UserScript==\n'
PROPS_REGEX = r'// @(?P<prop>[^\s]+)\s*(?P<val>.*)'
@@ -93,7 +99,7 @@ class GreasemonkeyScript:
"""Return the processed JavaScript code of this script.
Adorns the source code with GM_* methods for Greasemonkey
- compatibility and wraps it in an IFFE to hide it within a
+ compatibility and wraps it in an IIFE to hide it within a
lexical scope. Note that this means line numbers in your
browser's debugger/inspector will not match up to the line
numbers in the source script directly.
@@ -115,6 +121,14 @@ class GreasemonkeyScript:
'run-at': self.run_at,
})
+ def add_required_script(self, source):
+ """Add the source of a required script to this script."""
+ # The additional source is indented in case it also contains a
+ # metadata block. Because we pass everything at once to
+ # QWebEngineScript and that would parse the first metadata block
+ # found as the valid one.
+ self._code = "\n".join([textwrap.indent(source, " "), self._code])
+
@attr.s
class MatchingScripts(object):
@@ -145,15 +159,24 @@ class GreasemonkeyManager(QObject):
def __init__(self, parent=None):
super().__init__(parent)
+ self._run_start = []
+ self._run_end = []
+ self._run_idle = []
+ self._in_progress_dls = []
+
self.load_scripts()
@cmdutils.register(name='greasemonkey-reload',
instance='greasemonkey')
- def load_scripts(self):
+ def load_scripts(self, force=False):
"""Re-read Greasemonkey scripts from disk.
The scripts are read from a 'greasemonkey' subdirectory in
qutebrowser's data directory (see `:version`).
+
+ Args:
+ force: For any scripts that have required dependencies,
+ re-download them.
"""
self._run_start = []
self._run_end = []
@@ -169,24 +192,115 @@ class GreasemonkeyManager(QObject):
script = GreasemonkeyScript.parse(script_file.read())
if not script.name:
script.name = script_filename
-
- if script.run_at == 'document-start':
- self._run_start.append(script)
- elif script.run_at == 'document-end':
- self._run_end.append(script)
- elif script.run_at == 'document-idle':
- self._run_idle.append(script)
- else:
- if script.run_at:
- log.greasemonkey.warning(
- "Script {} has invalid run-at defined, "
- "defaulting to document-end".format(script_path))
- # Default as per
- # https://wiki.greasespot.net/Metadata_Block#.40run-at
- self._run_end.append(script)
- log.greasemonkey.debug("Loaded script: {}".format(script.name))
+ self.add_script(script, force)
self.scripts_reloaded.emit()
+ def add_script(self, script, force=False):
+ """Add a GreasemonkeyScript to this manager.
+
+ Args:
+ force: Fetch and overwrite any dependancies which are
+ already locally cached.
+ """
+ if script.requires:
+ log.greasemonkey.debug(
+ "Deferring script until requirements are "
+ "fulfilled: {}".format(script.name))
+ self._get_required_scripts(script, force)
+ else:
+ self._add_script(script)
+
+ def _add_script(self, script):
+ if script.run_at == 'document-start':
+ self._run_start.append(script)
+ elif script.run_at == 'document-end':
+ self._run_end.append(script)
+ elif script.run_at == 'document-idle':
+ self._run_idle.append(script)
+ else:
+ if script.run_at:
+ log.greasemonkey.warning("Script {} has invalid run-at "
+ "defined, defaulting to "
+ "document-end"
+ .format(script.name))
+ # Default as per
+ # https://wiki.greasespot.net/Metadata_Block#.40run-at
+ self._run_end.append(script)
+ log.greasemonkey.debug("Loaded script: {}".format(script.name))
+
+ def _required_url_to_file_path(self, url):
+ requires_dir = os.path.join(_scripts_dir(), 'requires')
+ if not os.path.exists(requires_dir):
+ os.mkdir(requires_dir)
+ return os.path.join(requires_dir, utils.sanitize_filename(url))
+
+ def _on_required_download_finished(self, script, download):
+ self._in_progress_dls.remove(download)
+ if not self._add_script_with_requires(script):
+ log.greasemonkey.debug(
+ "Finished download {} for script {} "
+ "but some requirements are still pending"
+ .format(download.basename, script.name))
+
+ def _add_script_with_requires(self, script, quiet=False):
+ """Add a script with pending downloads to this GreasemonkeyManager.
+
+ Specifically a script that has dependancies specified via an
+ `@require` rule.
+
+ Args:
+ script: The GreasemonkeyScript to add.
+ quiet: True to suppress the scripts_reloaded signal after
+ adding `script`.
+ Returns: True if the script was added, False if there are still
+ dependancies being downloaded.
+ """
+ # See if we are still waiting on any required scripts for this one
+ for dl in self._in_progress_dls:
+ if dl.requested_url in script.requires:
+ return False
+
+ # Need to add the required scripts to the IIFE now
+ for url in reversed(script.requires):
+ target_path = self._required_url_to_file_path(url)
+ log.greasemonkey.debug(
+ "Adding required script for {} to IIFE: {}"
+ .format(script.name, url))
+ with open(target_path, encoding='utf8') as f:
+ script.add_required_script(f.read())
+
+ self._add_script(script)
+ if not quiet:
+ self.scripts_reloaded.emit()
+ return True
+
+ def _get_required_scripts(self, script, force=False):
+ required_dls = [(url, self._required_url_to_file_path(url))
+ for url in script.requires]
+ if not force:
+ required_dls = [(url, path) for (url, path) in required_dls
+ if not os.path.exists(path)]
+ if not required_dls:
+ # All the required files exist already
+ self._add_script_with_requires(script, quiet=True)
+ return
+
+ download_manager = objreg.get('qtnetwork-download-manager')
+
+ for url, target_path in required_dls:
+ target = downloads.FileDownloadTarget(target_path,
+ force_overwrite=True)
+ download = download_manager.get(QUrl(url), target=target,
+ auto_remove=True)
+ download.requested_url = url
+ self._in_progress_dls.append(download)
+ if download.successful:
+ self._on_required_download_finished(script, download)
+ else:
+ download.finished.connect(
+ functools.partial(self._on_required_download_finished,
+ script, download))
+
def scripts_for(self, url):
"""Fetch scripts that are registered to run for url.
diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py
index 0390d5d1f..f7bcd713c 100644
--- a/qutebrowser/browser/hints.py
+++ b/qutebrowser/browser/hints.py
@@ -682,7 +682,7 @@ class HintManager(QObject):
"""
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
- tab = tabbed_browser.currentWidget()
+ tab = tabbed_browser.widget.currentWidget()
if tab is None:
raise cmdexc.CommandError("No WebView available yet!")
@@ -909,20 +909,27 @@ class HintManager(QObject):
@cmdutils.register(instance='hintmanager', scope='tab',
modes=[usertypes.KeyMode.hint])
- def follow_hint(self, keystring=None):
+ def follow_hint(self, select=False, keystring=None):
"""Follow a hint.
Args:
+ select: Only select the given hint, don't necessarily follow it.
keystring: The hint to follow, or None.
"""
if keystring is None:
if self._context.to_follow is None:
raise cmdexc.CommandError("No hint to follow")
+ elif select:
+ raise cmdexc.CommandError("Can't use --select without hint.")
else:
keystring = self._context.to_follow
elif keystring not in self._context.labels:
raise cmdexc.CommandError("No hint {}!".format(keystring))
- self._fire(keystring)
+
+ if select:
+ self.handle_partial_key(keystring)
+ else:
+ self._fire(keystring)
@pyqtSlot(usertypes.KeyMode)
def on_mode_left(self, mode):
diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py
index b0053cbf1..16a7f227e 100644
--- a/qutebrowser/browser/mouse.py
+++ b/qutebrowser/browser/mouse.py
@@ -151,8 +151,9 @@ class MouseEventFilter(QObject):
if elem.is_editable():
log.mouse.debug("Clicked editable element!")
- modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
- 'click', only_if_normal=True)
+ if config.val.input.insert_mode.auto_enter:
+ modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
+ 'click', only_if_normal=True)
else:
log.mouse.debug("Clicked non-editable element!")
if config.val.input.insert_mode.auto_leave:
diff --git a/qutebrowser/browser/network/proxy.py b/qutebrowser/browser/network/proxy.py
index 96be78742..d3e25c23c 100644
--- a/qutebrowser/browser/network/proxy.py
+++ b/qutebrowser/browser/network/proxy.py
@@ -34,6 +34,10 @@ def init():
QNetworkProxyFactory.setApplicationProxyFactory(proxy_factory)
+def shutdown():
+ QNetworkProxyFactory.setApplicationProxyFactory(None)
+
+
class ProxyFactory(QNetworkProxyFactory):
"""Factory for proxies to be used by qutebrowser."""
diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py
index cca74cb63..8866f1643 100644
--- a/qutebrowser/browser/qutescheme.py
+++ b/qutebrowser/browser/qutescheme.py
@@ -30,8 +30,10 @@ import time
import textwrap
import mimetypes
import urllib
+import collections
import pkg_resources
+import sip
from PyQt5.QtCore import QUrlQuery, QUrl
import qutebrowser
@@ -201,6 +203,27 @@ def qute_bookmarks(_url):
return 'text/html', html
+@add_handler('tabs')
+def qute_tabs(_url):
+ """Handler for qute://tabs. Display information about all open tabs."""
+ tabs = collections.defaultdict(list)
+ for win_id, window in objreg.window_registry.items():
+ if sip.isdeleted(window):
+ continue
+ tabbed_browser = objreg.get('tabbed-browser',
+ scope='window',
+ window=win_id)
+ for tab in tabbed_browser.widgets():
+ if tab.url() not in [QUrl("qute://tabs/"), QUrl("qute://tabs")]:
+ urlstr = tab.url().toDisplayString()
+ tabs[str(win_id)].append((tab.title(), urlstr))
+
+ html = jinja.render('tabs.html',
+ title='Tabs',
+ tab_list_by_window=tabs)
+ return 'text/html', html
+
+
def history_data(start_time, offset=None):
"""Return history data.
@@ -240,8 +263,6 @@ def qute_history(url):
return 'text/html', json.dumps(history_data(start_time, offset))
else:
- if not config.val.content.javascript.enabled:
- return 'text/plain', b'JavaScript is required for qute://history'
return 'text/html', jinja.render(
'history.html',
title='History',
diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py
index d82b741e5..238fdc1cc 100644
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -74,14 +74,15 @@ def authentication_required(url, authenticator, abort_on):
return answer
-def javascript_confirm(url, js_msg, abort_on):
+def javascript_confirm(url, js_msg, abort_on, *, escape_msg=True):
"""Display a javascript confirm prompt."""
log.js.debug("confirm: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
raise CallSuper
+ js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = 'From <b>{}</b>:<br/>{}'.format(html.escape(url.toDisplayString()),
- html.escape(js_msg))
+ js_msg)
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
ans = message.ask('Javascript confirm', msg,
mode=usertypes.PromptMode.yesno,
@@ -89,7 +90,7 @@ def javascript_confirm(url, js_msg, abort_on):
return bool(ans)
-def javascript_prompt(url, js_msg, default, abort_on):
+def javascript_prompt(url, js_msg, default, abort_on, *, escape_msg=True):
"""Display a javascript prompt."""
log.js.debug("prompt: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
@@ -97,8 +98,9 @@ def javascript_prompt(url, js_msg, default, abort_on):
if not config.val.content.javascript.prompt:
return (False, "")
+ js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = '<b>{}</b> asks:<br/>{}'.format(html.escape(url.toDisplayString()),
- html.escape(js_msg))
+ js_msg)
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
answer = message.ask('Javascript prompt', msg,
mode=usertypes.PromptMode.text,
@@ -111,7 +113,7 @@ def javascript_prompt(url, js_msg, default, abort_on):
return (True, answer)
-def javascript_alert(url, js_msg, abort_on):
+def javascript_alert(url, js_msg, abort_on, *, escape_msg=True):
"""Display a javascript alert."""
log.js.debug("alert: {}".format(js_msg))
if config.val.content.javascript.modal_dialog:
@@ -120,8 +122,9 @@ def javascript_alert(url, js_msg, abort_on):
if not config.val.content.javascript.alert:
return
+ js_msg = html.escape(js_msg) if escape_msg else js_msg
msg = 'From <b>{}</b>:<br/>{}'.format(html.escape(url.toDisplayString()),
- html.escape(js_msg))
+ js_msg)
urlstr = url.toString(QUrl.RemovePassword | QUrl.FullyEncoded)
message.ask('Javascript alert', msg, mode=usertypes.PromptMode.alert,
abort_on=abort_on, url=urlstr)
diff --git a/qutebrowser/browser/signalfilter.py b/qutebrowser/browser/signalfilter.py
index 663aa67e7..7cc46abdb 100644
--- a/qutebrowser/browser/signalfilter.py
+++ b/qutebrowser/browser/signalfilter.py
@@ -76,11 +76,11 @@ class SignalFilter(QObject):
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
try:
- tabidx = tabbed_browser.indexOf(tab)
+ tabidx = tabbed_browser.widget.indexOf(tab)
except RuntimeError:
# The tab has been deleted already
return
- if tabidx == tabbed_browser.currentIndex():
+ if tabidx == tabbed_browser.widget.currentIndex():
if log_signal:
log.signals.debug("emitting: {} (tab {})".format(
debug.dbg_signal(signal, args), tabidx))
diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py
index d2f563bb6..0a0dfb4f2 100644
--- a/qutebrowser/browser/urlmarks.py
+++ b/qutebrowser/browser/urlmarks.py
@@ -280,7 +280,7 @@ class BookmarkManager(UrlMarkManager):
if urlstr in self.marks:
if toggle:
- del self.marks[urlstr]
+ self.delete(urlstr)
return False
else:
raise AlreadyExistsError("Bookmark already exists!")
diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py
index 122e7d031..dee21c2d6 100644
--- a/qutebrowser/browser/webelem.py
+++ b/qutebrowser/browser/webelem.py
@@ -41,8 +41,8 @@ Group = enum.Enum('Group', ['all', 'links', 'images', 'url', 'inputs'])
SELECTORS = {
Group.all: ('a, area, textarea, select, input:not([type=hidden]), button, '
- 'frame, iframe, link, [onclick], [onmousedown], [role=link], '
- '[role=option], [role=button], img, '
+ 'frame, iframe, link, summary, [onclick], [onmousedown], '
+ '[role=link], [role=option], [role=button], img, '
# Angular 1 selectors
'[ng-click], [ngClick], [data-ng-click], [x-ng-click]'),
Group.links: 'a[href], area[href], link[href], [role=link][href]',
@@ -411,8 +411,9 @@ class AbstractWebElement(collections.abc.MutableMapping):
elif self.is_editable(strict=True):
log.webelem.debug("Clicking via JS focus()")
self._click_editable(click_target)
- modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
- 'clicking input')
+ if config.val.input.insert_mode.auto_enter:
+ modeman.enter(self._tab.win_id, usertypes.KeyMode.insert,
+ 'clicking input')
else:
self._click_fake_event(click_target)
elif click_target in [usertypes.ClickTarget.tab,
diff --git a/qutebrowser/browser/webengine/webengineinspector.py b/qutebrowser/browser/webengine/webengineinspector.py
index b2a6ebb1e..9200e3eb3 100644
--- a/qutebrowser/browser/webengine/webengineinspector.py
+++ b/qutebrowser/browser/webengine/webengineinspector.py
@@ -43,8 +43,8 @@ class WebEngineInspector(inspector.AbstractWebInspector):
port = int(os.environ['QTWEBENGINE_REMOTE_DEBUGGING'])
except KeyError:
raise inspector.WebInspectorError(
- "Debugging is not enabled. See 'qutebrowser --help' for "
- "details.")
+ "QtWebEngine inspector is not enabled. See "
+ "'qutebrowser --help' for details.")
url = QUrl('http://localhost:{}/'.format(port))
self._widget.load(url)
self.show()
diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py
index 607499401..46bfcab59 100644
--- a/qutebrowser/browser/webengine/webenginesettings.py
+++ b/qutebrowser/browser/webengine/webenginesettings.py
@@ -17,9 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# We get various "abstract but not overridden" warnings
-# pylint: disable=abstract-method
-
"""Bridge from QWebEngineSettings to our own settings.
Module attributes:
@@ -44,116 +41,132 @@ from qutebrowser.utils import (utils, standarddir, javascript, qtutils,
default_profile = None
# The QWebEngineProfile used for private (off-the-record) windows
private_profile = None
+# The global WebEngineSettings object
+global_settings = None
-class Base(websettings.Base):
-
- """Base settings class with appropriate _get_global_settings."""
-
- def _get_global_settings(self):
- return [default_profile.settings(), private_profile.settings()]
-
-
-class Attribute(Base, websettings.Attribute):
-
- """A setting set via QWebEngineSettings::setAttribute."""
-
- ENUM_BASE = QWebEngineSettings
-
-
-class Setter(Base, websettings.Setter):
+class _SettingsWrapper:
- """A setting set via a QWebEngineSettings setter method."""
+ """Expose a QWebEngineSettings interface which acts on all profiles.
- pass
-
-
-class FontFamilySetter(Base, websettings.FontFamilySetter):
-
- """A setter for a font family.
-
- Gets the default value from QFont.
+ For read operations, the default profile value is always used.
"""
- def __init__(self, font):
- # Mapping from WebEngineSettings::initDefaults in
- # qtwebengine/src/core/web_engine_settings.cpp
- font_to_qfont = {
- QWebEngineSettings.StandardFont: QFont.Serif,
- QWebEngineSettings.FixedFont: QFont.Monospace,
- QWebEngineSettings.SerifFont: QFont.Serif,
- QWebEngineSettings.SansSerifFont: QFont.SansSerif,
- QWebEngineSettings.CursiveFont: QFont.Cursive,
- QWebEngineSettings.FantasyFont: QFont.Fantasy,
- }
- super().__init__(setter=QWebEngineSettings.setFontFamily, font=font,
- qfont=font_to_qfont[font])
-
-
-class DefaultProfileSetter(websettings.Base):
-
- """A setting set on the QWebEngineProfile."""
-
- def __init__(self, setter, converter=None, default=websettings.UNSET):
- super().__init__(default)
- self._setter = setter
- self._converter = converter
-
- def __repr__(self):
- return utils.get_repr(self, setter=self._setter, constructor=True)
-
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with "
- "DefaultProfileSetters!")
-
- setter = getattr(default_profile, self._setter)
- if self._converter is not None:
- value = self._converter(value)
-
- setter(value)
-
-
-class PersistentCookiePolicy(DefaultProfileSetter):
-
- """The content.cookies.store setting is different from other settings."""
-
- def __init__(self):
- super().__init__('setPersistentCookiesPolicy')
-
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with "
- "PersistentCookiePolicy!")
- setter = getattr(QWebEngineProfile.defaultProfile(), self._setter)
- setter(
- QWebEngineProfile.AllowPersistentCookies if value else
- QWebEngineProfile.NoPersistentCookies
- )
-
-
-class DictionaryLanguageSetter(DefaultProfileSetter):
-
- """Sets paths to dictionary files based on language codes."""
-
def __init__(self):
- super().__init__('setSpellCheckLanguages', default=[])
-
- def _find_installed(self, code):
- local_filename = spell.local_filename(code)
- if not local_filename:
- message.warning(
- "Language {} is not installed - see scripts/dictcli.py "
- "in qutebrowser's sources".format(code))
- return local_filename
+ self._settings = [default_profile.settings(),
+ private_profile.settings()]
+
+ def setAttribute(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setAttribute(*args, **kwargs)
+
+ def setFontFamily(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setFontFamily(*args, **kwargs)
+
+ def setFontSize(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setFontSize(*args, **kwargs)
+
+ def setDefaultTextEncoding(self, *args, **kwargs):
+ for settings in self._settings:
+ settings.setDefaultTextEncoding(*args, **kwargs)
+
+ def testAttribute(self, *args, **kwargs):
+ return self._settings[0].testAttribute(*args, **kwargs)
+
+ def fontSize(self, *args, **kwargs):
+ return self._settings[0].fontSize(*args, **kwargs)
+
+ def fontFamily(self, *args, **kwargs):
+ return self._settings[0].fontFamily(*args, **kwargs)
+
+ def defaultTextEncoding(self, *args, **kwargs):
+ return self._settings[0].defaultTextEncoding(*args, **kwargs)
+
+
+class WebEngineSettings(websettings.AbstractSettings):
+
+ """A wrapper for the config for QWebEngineSettings."""
+
+ _ATTRIBUTES = {
+ 'content.xss_auditing':
+ [QWebEngineSettings.XSSAuditingEnabled],
+ 'content.images':
+ [QWebEngineSettings.AutoLoadImages],
+ 'content.javascript.enabled':
+ [QWebEngineSettings.JavascriptEnabled],
+ 'content.javascript.can_open_tabs_automatically':
+ [QWebEngineSettings.JavascriptCanOpenWindows],
+ 'content.javascript.can_access_clipboard':
+ [QWebEngineSettings.JavascriptCanAccessClipboard],
+ 'content.plugins':
+ [QWebEngineSettings.PluginsEnabled],
+ 'content.hyperlink_auditing':
+ [QWebEngineSettings.HyperlinkAuditingEnabled],
+ 'content.local_content_can_access_remote_urls':
+ [QWebEngineSettings.LocalContentCanAccessRemoteUrls],
+ 'content.local_content_can_access_file_urls':
+ [QWebEngineSettings.LocalContentCanAccessFileUrls],
+ 'content.webgl':
+ [QWebEngineSettings.WebGLEnabled],
+ 'content.local_storage':
+ [QWebEngineSettings.LocalStorageEnabled],
+
+ 'input.spatial_navigation':
+ [QWebEngineSettings.SpatialNavigationEnabled],
+ 'input.links_included_in_focus_chain':
+ [QWebEngineSettings.LinksIncludedInFocusChain],
+
+ 'scrolling.smooth':
+ [QWebEngineSettings.ScrollAnimatorEnabled],
+ }
+
+ _FONT_SIZES = {
+ 'fonts.web.size.minimum':
+ QWebEngineSettings.MinimumFontSize,
+ 'fonts.web.size.minimum_logical':
+ QWebEngineSettings.MinimumLogicalFontSize,
+ 'fonts.web.size.default':
+ QWebEngineSettings.DefaultFontSize,
+ 'fonts.web.size.default_fixed':
+ QWebEngineSettings.DefaultFixedFontSize,
+ }
+
+ _FONT_FAMILIES = {
+ 'fonts.web.family.standard': QWebEngineSettings.StandardFont,
+ 'fonts.web.family.fixed': QWebEngineSettings.FixedFont,
+ 'fonts.web.family.serif': QWebEngineSettings.SerifFont,
+ 'fonts.web.family.sans_serif': QWebEngineSettings.SansSerifFont,
+ 'fonts.web.family.cursive': QWebEngineSettings.CursiveFont,
+ 'fonts.web.family.fantasy': QWebEngineSettings.FantasyFont,
+ }
+
+ # Mapping from WebEngineSettings::initDefaults in
+ # qtwebengine/src/core/web_engine_settings.cpp
+ _FONT_TO_QFONT = {
+ QWebEngineSettings.StandardFont: QFont.Serif,
+ QWebEngineSettings.FixedFont: QFont.Monospace,
+ QWebEngineSettings.SerifFont: QFont.Serif,
+ QWebEngineSettings.SansSerifFont: QFont.SansSerif,
+ QWebEngineSettings.CursiveFont: QFont.Cursive,
+ QWebEngineSettings.FantasyFont: QFont.Fantasy,
+ }
+
+ def __init__(self, settings):
+ super().__init__(settings)
+ # Attributes which don't exist in all Qt versions.
+ new_attributes = {
+ # Qt 5.8
+ 'content.print_element_backgrounds': 'PrintElementBackgrounds',
+ }
+ for name, attribute in new_attributes.items():
+ try:
+ value = getattr(QWebEngineSettings, attribute)
+ except AttributeError:
+ continue
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with "
- "DictionaryLanguageSetter!")
- filenames = [self._find_installed(code) for code in value]
- log.config.debug("Found dicts: {}".format(filenames))
- super()._set([f for f in filenames if f], settings)
+ self._ATTRIBUTES[name] = [value]
def _init_stylesheet(profile):
@@ -210,9 +223,48 @@ def _set_http_headers(profile):
profile.setHttpAcceptLanguage(accept_language)
+def _set_http_cache_size(profile):
+ """Initialize the HTTP cache size for the given profile."""
+ size = config.val.content.cache.size
+ if size is None:
+ size = 0
+ else:
+ size = qtutils.check_overflow(size, 'int', fatal=False)
+
+ # 0: automatically managed by QtWebEngine
+ profile.setHttpCacheMaximumSize(size)
+
+
+def _set_persistent_cookie_policy(profile):
+ """Set the HTTP Cookie size for the given profile."""
+ if config.val.content.cookies.store:
+ value = QWebEngineProfile.AllowPersistentCookies
+ else:
+ value = QWebEngineProfile.NoPersistentCookies
+ profile.setPersistentCookiesPolicy(value)
+
+
+def _set_dictionary_language(profile, warn=True):
+ filenames = []
+ for code in config.val.spellcheck.languages or []:
+ local_filename = spell.local_filename(code)
+ if not local_filename:
+ if warn:
+ message.warning(
+ "Language {} is not installed - see scripts/dictcli.py "
+ "in qutebrowser's sources".format(code))
+ continue
+
+ filenames.append(local_filename)
+
+ log.config.debug("Found dicts: {}".format(filenames))
+ profile.setSpellCheckLanguages(filenames)
+
+
def _update_settings(option):
"""Update global settings when qwebsettings changed."""
- websettings.update_mappings(MAPPINGS, option)
+ global_settings.update_setting(option)
+
if option in ['scrolling.bar', 'content.user_stylesheets']:
_init_stylesheet(default_profile)
_init_stylesheet(private_profile)
@@ -221,27 +273,46 @@ def _update_settings(option):
'content.headers.accept_language']:
_set_http_headers(default_profile)
_set_http_headers(private_profile)
+ elif option == 'content.cache.size':
+ _set_http_cache_size(default_profile)
+ _set_http_cache_size(private_profile)
+ elif (option == 'content.cookies.store' and
+ # https://bugreports.qt.io/browse/QTBUG-58650
+ qtutils.version_check('5.9', compiled=False)):
+ _set_persistent_cookie_policy(default_profile)
+ # We're not touching the private profile's cookie policy.
+ elif option == 'spellcheck.languages':
+ _set_dictionary_language(default_profile)
+ _set_dictionary_language(private_profile, warn=False)
+
+
+def _init_profile(profile):
+ """Init the given profile."""
+ _init_stylesheet(profile)
+ _set_http_headers(profile)
+ _set_http_cache_size(profile)
+ profile.settings().setAttribute(
+ QWebEngineSettings.FullScreenSupportEnabled, True)
+ if qtutils.version_check('5.8'):
+ profile.setSpellCheckEnabled(True)
+ _set_dictionary_language(profile)
def _init_profiles():
"""Init the two used QWebEngineProfiles."""
global default_profile, private_profile
+
default_profile = QWebEngineProfile.defaultProfile()
default_profile.setCachePath(
os.path.join(standarddir.cache(), 'webengine'))
default_profile.setPersistentStoragePath(
os.path.join(standarddir.data(), 'webengine'))
- _init_stylesheet(default_profile)
- _set_http_headers(default_profile)
+ _init_profile(default_profile)
+ _set_persistent_cookie_policy(default_profile)
private_profile = QWebEngineProfile()
assert private_profile.isOffTheRecord()
- _init_stylesheet(private_profile)
- _set_http_headers(private_profile)
-
- if qtutils.version_check('5.8'):
- default_profile.setSpellCheckEnabled(True)
- private_profile.setSpellCheckEnabled(True)
+ _init_profile(private_profile)
def inject_userscripts():
@@ -287,111 +358,12 @@ def init(args):
os.environ['QTWEBENGINE_REMOTE_DEBUGGING'] = str(utils.random_port())
_init_profiles()
-
- # We need to do this here as a WORKAROUND for
- # https://bugreports.qt.io/browse/QTBUG-58650
- if not qtutils.version_check('5.9', compiled=False):
- PersistentCookiePolicy().set(config.val.content.cookies.store)
- Attribute(QWebEngineSettings.FullScreenSupportEnabled).set(True)
-
- websettings.init_mappings(MAPPINGS)
config.instance.changed.connect(_update_settings)
-
-def shutdown():
- # FIXME:qtwebengine do we need to do something for a clean shutdown here?
- pass
+ global global_settings
+ global_settings = WebEngineSettings(_SettingsWrapper())
+ global_settings.init_settings()
-# Missing QtWebEngine attributes:
-# - ScreenCaptureEnabled
-# - Accelerated2dCanvasEnabled
-# - AutoLoadIconsForPage
-# - TouchIconsEnabled
-# - FocusOnNavigationEnabled (5.8)
-# - AllowRunningInsecureContent (5.8)
-#
-# Missing QtWebEngine fonts:
-# - PictographFont
-
-
-MAPPINGS = {
- 'content.images':
- Attribute(QWebEngineSettings.AutoLoadImages),
- 'content.javascript.enabled':
- Attribute(QWebEngineSettings.JavascriptEnabled),
- 'content.javascript.can_open_tabs_automatically':
- Attribute(QWebEngineSettings.JavascriptCanOpenWindows),
- 'content.javascript.can_access_clipboard':
- Attribute(QWebEngineSettings.JavascriptCanAccessClipboard),
- 'content.plugins':
- Attribute(QWebEngineSettings.PluginsEnabled),
- 'content.hyperlink_auditing':
- Attribute(QWebEngineSettings.HyperlinkAuditingEnabled),
- 'content.local_content_can_access_remote_urls':
- Attribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls),
- 'content.local_content_can_access_file_urls':
- Attribute(QWebEngineSettings.LocalContentCanAccessFileUrls),
- 'content.webgl':
- Attribute(QWebEngineSettings.WebGLEnabled),
- 'content.local_storage':
- Attribute(QWebEngineSettings.LocalStorageEnabled),
- 'content.cache.size':
- # 0: automatically managed by QtWebEngine
- DefaultProfileSetter('setHttpCacheMaximumSize', default=0,
- converter=lambda val:
- qtutils.check_overflow(val, 'int', fatal=False)),
- 'content.xss_auditing':
- Attribute(QWebEngineSettings.XSSAuditingEnabled),
- 'content.default_encoding':
- Setter(QWebEngineSettings.setDefaultTextEncoding),
-
- 'input.spatial_navigation':
- Attribute(QWebEngineSettings.SpatialNavigationEnabled),
- 'input.links_included_in_focus_chain':
- Attribute(QWebEngineSettings.LinksIncludedInFocusChain),
-
- 'fonts.web.family.standard':
- FontFamilySetter(QWebEngineSettings.StandardFont),
- 'fonts.web.family.fixed':
- FontFamilySetter(QWebEngineSettings.FixedFont),
- 'fonts.web.family.serif':
- FontFamilySetter(QWebEngineSettings.SerifFont),
- 'fonts.web.family.sans_serif':
- FontFamilySetter(QWebEngineSettings.SansSerifFont),
- 'fonts.web.family.cursive':
- FontFamilySetter(QWebEngineSettings.CursiveFont),
- 'fonts.web.family.fantasy':
- FontFamilySetter(QWebEngineSettings.FantasyFont),
- 'fonts.web.size.minimum':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.MinimumFontSize]),
- 'fonts.web.size.minimum_logical':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.MinimumLogicalFontSize]),
- 'fonts.web.size.default':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.DefaultFontSize]),
- 'fonts.web.size.default_fixed':
- Setter(QWebEngineSettings.setFontSize,
- args=[QWebEngineSettings.DefaultFixedFontSize]),
-
- 'scrolling.smooth':
- Attribute(QWebEngineSettings.ScrollAnimatorEnabled),
-}
-
-try:
- MAPPINGS['content.print_element_backgrounds'] = Attribute(
- QWebEngineSettings.PrintElementBackgrounds)
-except AttributeError:
- # Added in Qt 5.8
+def shutdown():
pass
-
-
-if qtutils.version_check('5.8'):
- MAPPINGS['spellcheck.languages'] = DictionaryLanguageSetter()
-
-
-if qtutils.version_check('5.9', compiled=False):
- # https://bugreports.qt.io/browse/QTBUG-58650
- MAPPINGS['content.cookies.store'] = PersistentCookiePolicy()
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index ed6697f03..87adbc81e 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -22,16 +22,18 @@
import math
import functools
import sys
+import re
import html as html_utils
import sip
from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QEvent, QPoint, QPointF,
- QUrl)
-from PyQt5.QtGui import QKeyEvent
+ QUrl, QTimer)
+from PyQt5.QtGui import QKeyEvent, QIcon
from PyQt5.QtNetwork import QAuthenticator
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript
+from qutebrowser.config import configdata
from qutebrowser.browser import browsertab, mouse, shared
from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory,
interceptor, webenginequtescheme,
@@ -183,6 +185,12 @@ class WebEngineSearch(browsertab.AbstractSearch):
def search(self, text, *, ignore_case='never', reverse=False,
result_cb=None):
+ # Don't go to next entry on duplicate search
+ if self.text == text and self.search_displayed:
+ log.webview.debug("Ignoring duplicate search request"
+ " for {}".format(text))
+ return
+
self.text = text
self._flags = QWebEnginePage.FindFlags(0)
if self._is_case_sensitive(ignore_case):
@@ -218,12 +226,21 @@ class WebEngineCaret(browsertab.AbstractCaret):
if mode != usertypes.KeyMode.caret:
return
+ if self._tab.search.search_displayed:
+ # We are currently in search mode.
+ # convert the search to a blue selection so we can operate on it
+ # https://bugreports.qt.io/browse/QTBUG-60673
+ self._tab.search.clear()
+
self._tab.run_js_async(
javascript.assemble('caret', 'setPlatform', sys.platform))
self._js_call('setInitialCursor')
@pyqtSlot(usertypes.KeyMode)
- def _on_mode_left(self):
+ def _on_mode_left(self, mode):
+ if mode != usertypes.KeyMode.caret:
+ return
+
self.drop_selection()
self._js_call('disableCaret')
@@ -470,7 +487,8 @@ class WebEngineHistory(browsertab.AbstractHistory):
return self._history.itemAt(i)
def _go_to_item(self, item):
- return self._history.goToItem(item)
+ self._tab.predicted_navigation.emit(item.url())
+ self._history.goToItem(item)
def serialize(self):
if not qtutils.version_check('5.9', compiled=False):
@@ -488,6 +506,9 @@ class WebEngineHistory(browsertab.AbstractHistory):
return qtutils.deserialize(data, self._history)
def load_items(self, items):
+ if items:
+ self._tab.predicted_navigation.emit(items[-1].url)
+
stream, _data, cur_data = tabhistory.serialize(items)
qtutils.deserialize_stream(stream, self._history)
@@ -604,12 +625,15 @@ class WebEngineTab(browsertab.AbstractTab):
self.printing = WebEnginePrinting()
self.elements = WebEngineElements(tab=self)
self.action = WebEngineAction(tab=self)
+ # We're assigning settings in _set_widget
+ self.settings = webenginesettings.WebEngineSettings(settings=None)
self._set_widget(widget)
self._connect_signals()
self.backend = usertypes.Backend.QtWebEngine
self._init_js()
self._child_event_filter = None
self._saved_zoom = None
+ self._reload_url = None
def _init_js(self):
js_code = '\n'.join([
@@ -648,9 +672,15 @@ class WebEngineTab(browsertab.AbstractTab):
self.zoom.set_factor(self._saved_zoom)
self._saved_zoom = None
- def openurl(self, url):
+ def openurl(self, url, *, predict=True):
+ """Open the given URL in this tab.
+
+ Arguments:
+ url: The QUrl to open.
+ predict: If set to False, predicted_navigation is not emitted.
+ """
self._saved_zoom = self.zoom.factor()
- self._openurl_prepare(url)
+ self._openurl_prepare(url, predict=predict)
self._widget.load(url)
def url(self, requested=False):
@@ -682,10 +712,6 @@ class WebEngineTab(browsertab.AbstractTab):
def shutdown(self):
self.shutting_down.emit()
self.action.exit_fullscreen()
- if qtutils.version_check('5.8', exact=True, compiled=False):
- # WORKAROUND for
- # https://bugreports.qt.io/browse/QTBUG-58563
- self.search.clear()
self._widget.shutdown()
def reload(self, *, force=False):
@@ -728,6 +754,16 @@ class WebEngineTab(browsertab.AbstractTab):
self.send_event(press_evt)
self.send_event(release_evt)
+ def _show_error_page(self, url, error):
+ """Show an error page in the tab."""
+ log.misc.debug("Showing error page for {}".format(error))
+ url_string = url.toDisplayString()
+ error_page = jinja.render(
+ 'error.html',
+ title="Error loading page: {}".format(url_string),
+ url=url_string, error=error)
+ self.set_html(error_page)
+
@pyqtSlot()
def _on_history_trigger(self):
try:
@@ -776,13 +812,7 @@ class WebEngineTab(browsertab.AbstractTab):
sip.assign(authenticator, QAuthenticator())
# pylint: enable=no-member, useless-suppression
except AttributeError:
- url_string = url.toDisplayString()
- error_page = jinja.render(
- 'error.html',
- title="Error loading page: {}".format(url_string),
- url=url_string, error="Proxy authentication required",
- icon='')
- self.set_html(error_page)
+ self._show_error_page(url, "Proxy authentication required")
@pyqtSlot(QUrl, 'QAuthenticator*')
def _on_authentication_required(self, url, authenticator):
@@ -802,12 +832,7 @@ class WebEngineTab(browsertab.AbstractTab):
except AttributeError:
# WORKAROUND for
# https://www.riverbankcomputing.com/pipermail/pyqt/2016-December/038400.html
- url_string = url.toDisplayString()
- error_page = jinja.render(
- 'error.html',
- title="Error loading page: {}".format(url_string),
- url=url_string, error="Authentication required")
- self.set_html(error_page)
+ self._show_error_page(url, "Authentication required")
@pyqtSlot('QWebEngineFullScreenRequest')
def _on_fullscreen_requested(self, request):
@@ -872,6 +897,74 @@ class WebEngineTab(browsertab.AbstractTab):
if not ok:
self._load_finished_fake.emit(False)
+ def _error_page_workaround(self, html):
+ """Check if we're displaying a Chromium error page.
+
+ This gets only called if we got loadFinished(False) without JavaScript,
+ so we can display at least some error page.
+
+ WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66643
+ Needs to check the page content as a WORKAROUND for
+ https://bugreports.qt.io/browse/QTBUG-66661
+ """
+ match = re.search(r'"errorCode":"([^"]*)"', html)
+ if match is None:
+ return
+ self._show_error_page(self.url(), error=match.group(1))
+
+ @pyqtSlot(bool)
+ def _on_load_finished(self, ok):
+ """Display a static error page if JavaScript is disabled."""
+ super()._on_load_finished(ok)
+ js_enabled = self.settings.test_attribute('content.javascript.enabled')
+ if not ok and not js_enabled:
+ self.dump_async(self._error_page_workaround)
+
+ if ok and self._reload_url is not None:
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
+ log.config.debug(
+ "Loading {} again because of config change".format(
+ self._reload_url.toDisplayString()))
+ QTimer.singleShot(100, lambda url=self._reload_url:
+ self.openurl(url, predict=False))
+ self._reload_url = None
+
+ if not qtutils.version_check('5.10', compiled=False):
+ # We can't do this when we have the loadFinished workaround as that
+ # sometimes clears icons without loading a new page.
+ # In general, this is handled by Qt, but when loading takes long,
+ # the old icon is still displayed.
+ self.icon_changed.emit(QIcon())
+
+ @pyqtSlot(QUrl)
+ def _on_predicted_navigation(self, url):
+ """If we know we're going to visit an URL soon, change the settings."""
+ super()._on_predicted_navigation(url)
+ self.settings.update_for_url(url)
+
+ @pyqtSlot(usertypes.NavigationRequest)
+ def _on_navigation_request(self, navigation):
+ super()._on_navigation_request(navigation)
+ if not navigation.accepted or not navigation.is_main_frame:
+ return
+
+ needs_reload = {
+ 'content.plugins',
+ 'content.javascript.enabled',
+ 'content.javascript.can_access_clipboard',
+ 'content.javascript.can_access_clipboard',
+ 'content.print_element_backgrounds',
+ 'input.spatial_navigation',
+ 'input.spatial_navigation',
+ }
+ assert needs_reload.issubset(configdata.DATA)
+
+ changed = self.settings.update_for_url(navigation.url)
+ if (changed & needs_reload and navigation.navigation_type !=
+ navigation.Type.link_clicked):
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656
+ self._reload_url = navigation.url
+
def _connect_signals(self):
view = self._widget
page = view.page()
@@ -886,6 +979,7 @@ class WebEngineTab(browsertab.AbstractTab):
self._on_proxy_authentication_required)
page.fullScreenRequested.connect(self._on_fullscreen_requested)
page.contentsSizeChanged.connect(self.contents_size_changed)
+ page.navigation_request.connect(self._on_navigation_request)
view.titleChanged.connect(self.title_changed)
view.urlChanged.connect(self._on_url_changed)
@@ -906,5 +1000,7 @@ class WebEngineTab(browsertab.AbstractTab):
page.loadFinished.connect(self._restore_zoom)
page.loadFinished.connect(self._on_load_finished)
+ self.predicted_navigation.connect(self._on_predicted_navigation)
+
def event_target(self):
return self._widget.focusProxy()
diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py
index 1b3c15f9e..70cd11e0d 100644
--- a/qutebrowser/browser/webengine/webview.py
+++ b/qutebrowser/browser/webengine/webview.py
@@ -29,8 +29,7 @@ from PyQt5.QtWebEngineWidgets import (QWebEngineView, QWebEnginePage,
from qutebrowser.browser import shared
from qutebrowser.browser.webengine import certificateerror, webenginesettings
from qutebrowser.config import config
-from qutebrowser.utils import (log, debug, usertypes, jinja, urlutils, message,
- objreg, qtutils)
+from qutebrowser.utils import log, debug, usertypes, jinja, objreg, qtutils
class WebEngineView(QWebEngineView):
@@ -124,10 +123,12 @@ class WebEnginePage(QWebEnginePage):
Signals:
certificate_error: Emitted on certificate errors.
shutting_down: Emitted when the page is shutting down.
+ navigation_request: Emitted on acceptNavigationRequest.
"""
certificate_error = pyqtSignal()
shutting_down = pyqtSignal()
+ navigation_request = pyqtSignal(usertypes.NavigationRequest)
def __init__(self, *, theme_color, profile, parent=None):
super().__init__(profile, parent)
@@ -242,10 +243,12 @@ class WebEnginePage(QWebEnginePage):
"""Override javaScriptConfirm to use qutebrowser prompts."""
if self._is_shutting_down:
return False
+ escape_msg = qtutils.version_check('5.11', compiled=False)
try:
return shared.javascript_confirm(url, js_msg,
abort_on=[self.loadStarted,
- self.shutting_down])
+ self.shutting_down],
+ escape_msg=escape_msg)
except shared.CallSuper:
return super().javaScriptConfirm(url, js_msg)
@@ -255,12 +258,14 @@ class WebEnginePage(QWebEnginePage):
# https://www.riverbankcomputing.com/pipermail/pyqt/2016-November/038293.html
def javaScriptPrompt(self, url, js_msg, default):
"""Override javaScriptPrompt to use qutebrowser prompts."""
+ escape_msg = qtutils.version_check('5.11', compiled=False)
if self._is_shutting_down:
return (False, "")
try:
return shared.javascript_prompt(url, js_msg, default,
abort_on=[self.loadStarted,
- self.shutting_down])
+ self.shutting_down],
+ escape_msg=escape_msg)
except shared.CallSuper:
return super().javaScriptPrompt(url, js_msg, default)
@@ -268,10 +273,12 @@ class WebEnginePage(QWebEnginePage):
"""Override javaScriptAlert to use qutebrowser prompts."""
if self._is_shutting_down:
return
+ escape_msg = qtutils.version_check('5.11', compiled=False)
try:
shared.javascript_alert(url, js_msg,
abort_on=[self.loadStarted,
- self.shutting_down])
+ self.shutting_down],
+ escape_msg=escape_msg)
except shared.CallSuper:
super().javaScriptAlert(url, js_msg)
@@ -288,21 +295,26 @@ class WebEnginePage(QWebEnginePage):
url: QUrl,
typ: QWebEnginePage.NavigationType,
is_main_frame: bool):
- """Override acceptNavigationRequest to handle clicked links.
-
- This only show an error on invalid links - everything else is handled
- in createWindow.
- """
- log.webview.debug("navigation request: url {}, type {}, is_main_frame "
- "{}".format(url.toDisplayString(),
- debug.qenum_key(QWebEnginePage, typ),
- is_main_frame))
- if (typ == QWebEnginePage.NavigationTypeLinkClicked and
- not url.isValid()):
- msg = urlutils.get_errstring(url, "Invalid link clicked")
- message.error(msg)
- return False
- return True
+ """Override acceptNavigationRequest to forward it to the tab API."""
+ type_map = {
+ QWebEnginePage.NavigationTypeLinkClicked:
+ usertypes.NavigationRequest.Type.link_clicked,
+ QWebEnginePage.NavigationTypeTyped:
+ usertypes.NavigationRequest.Type.typed,
+ QWebEnginePage.NavigationTypeFormSubmitted:
+ usertypes.NavigationRequest.Type.form_submitted,
+ QWebEnginePage.NavigationTypeBackForward:
+ usertypes.NavigationRequest.Type.back_forward,
+ QWebEnginePage.NavigationTypeReload:
+ usertypes.NavigationRequest.Type.reloaded,
+ QWebEnginePage.NavigationTypeOther:
+ usertypes.NavigationRequest.Type.other,
+ }
+ navigation = usertypes.NavigationRequest(url=url,
+ navigation_type=type_map[typ],
+ is_main_frame=is_main_frame)
+ self.navigation_request.emit(navigation)
+ return navigation.accepted
@pyqtSlot('QUrl')
def _inject_userjs(self, url):
diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py
index ee01c40db..9b120e514 100644
--- a/qutebrowser/browser/webkit/webkitsettings.py
+++ b/qutebrowser/browser/webkit/webkitsettings.py
@@ -17,9 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# We get various "abstract but not overridden" warnings
-# pylint: disable=abstract-method
-
"""Bridge from QWebSettings to our own settings.
Module attributes:
@@ -37,85 +34,130 @@ from qutebrowser.utils import standarddir, urlutils
from qutebrowser.browser import shared
-class Base(websettings.Base):
-
- """Base settings class with appropriate _get_global_settings."""
-
- def _get_global_settings(self):
- return [QWebSettings.globalSettings()]
-
-
-class Attribute(Base, websettings.Attribute):
-
- """A setting set via QWebSettings::setAttribute."""
-
- ENUM_BASE = QWebSettings
-
-
-class Setter(Base, websettings.Setter):
-
- """A setting set via a QWebSettings setter method."""
-
- pass
-
-
-class StaticSetter(Base, websettings.StaticSetter):
-
- """A setting set via a static QWebSettings setter method."""
-
- pass
-
-
-class FontFamilySetter(Base, websettings.FontFamilySetter):
+# The global WebKitSettings object
+global_settings = None
+
+
+class WebKitSettings(websettings.AbstractSettings):
+
+ """A wrapper for the config for QWebSettings."""
+
+ _ATTRIBUTES = {
+ 'content.images':
+ [QWebSettings.AutoLoadImages],
+ 'content.javascript.enabled':
+ [QWebSettings.JavascriptEnabled],
+ 'content.javascript.can_open_tabs_automatically':
+ [QWebSettings.JavascriptCanOpenWindows],
+ 'content.javascript.can_close_tabs':
+ [QWebSettings.JavascriptCanCloseWindows],
+ 'content.javascript.can_access_clipboard':
+ [QWebSettings.JavascriptCanAccessClipboard],
+ 'content.plugins':
+ [QWebSettings.PluginsEnabled],
+ 'content.webgl':
+ [QWebSettings.WebGLEnabled],
+ 'content.hyperlink_auditing':
+ [QWebSettings.HyperlinkAuditingEnabled],
+ 'content.local_content_can_access_remote_urls':
+ [QWebSettings.LocalContentCanAccessRemoteUrls],
+ 'content.local_content_can_access_file_urls':
+ [QWebSettings.LocalContentCanAccessFileUrls],
+ 'content.dns_prefetch':
+ [QWebSettings.DnsPrefetchEnabled],
+ 'content.frame_flattening':
+ [QWebSettings.FrameFlatteningEnabled],
+ 'content.cache.appcache':
+ [QWebSettings.OfflineWebApplicationCacheEnabled],
+ 'content.local_storage':
+ [QWebSettings.LocalStorageEnabled,
+ QWebSettings.OfflineStorageDatabaseEnabled],
+ 'content.developer_extras':
+ [QWebSettings.DeveloperExtrasEnabled],
+ 'content.print_element_backgrounds':
+ [QWebSettings.PrintElementBackgrounds],
+ 'content.xss_auditing':
+ [QWebSettings.XSSAuditingEnabled],
+
+ 'input.spatial_navigation':
+ [QWebSettings.SpatialNavigationEnabled],
+ 'input.links_included_in_focus_chain':
+ [QWebSettings.LinksIncludedInFocusChain],
+
+ 'zoom.text_only':
+ [QWebSettings.ZoomTextOnly],
+ 'scrolling.smooth':
+ [QWebSettings.ScrollAnimatorEnabled],
+ }
- """A setter for a font family.
+ _FONT_SIZES = {
+ 'fonts.web.size.minimum':
+ QWebSettings.MinimumFontSize,
+ 'fonts.web.size.minimum_logical':
+ QWebSettings.MinimumLogicalFontSize,
+ 'fonts.web.size.default':
+ QWebSettings.DefaultFontSize,
+ 'fonts.web.size.default_fixed':
+ QWebSettings.DefaultFixedFontSize,
+ }
- Gets the default value from QFont.
- """
+ _FONT_FAMILIES = {
+ 'fonts.web.family.standard': QWebSettings.StandardFont,
+ 'fonts.web.family.fixed': QWebSettings.FixedFont,
+ 'fonts.web.family.serif': QWebSettings.SerifFont,
+ 'fonts.web.family.sans_serif': QWebSettings.SansSerifFont,
+ 'fonts.web.family.cursive': QWebSettings.CursiveFont,
+ 'fonts.web.family.fantasy': QWebSettings.FantasyFont,
+ }
- def __init__(self, font):
- # Mapping from QWebSettings::QWebSettings() in
- # qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp
- font_to_qfont = {
- QWebSettings.StandardFont: QFont.Serif,
- QWebSettings.FixedFont: QFont.Monospace,
- QWebSettings.SerifFont: QFont.Serif,
- QWebSettings.SansSerifFont: QFont.SansSerif,
- QWebSettings.CursiveFont: QFont.Cursive,
- QWebSettings.FantasyFont: QFont.Fantasy,
- }
- super().__init__(setter=QWebSettings.setFontFamily, font=font,
- qfont=font_to_qfont[font])
+ # Mapping from QWebSettings::QWebSettings() in
+ # qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp
+ _FONT_TO_QFONT = {
+ QWebSettings.StandardFont: QFont.Serif,
+ QWebSettings.FixedFont: QFont.Monospace,
+ QWebSettings.SerifFont: QFont.Serif,
+ QWebSettings.SansSerifFont: QFont.SansSerif,
+ QWebSettings.CursiveFont: QFont.Cursive,
+ QWebSettings.FantasyFont: QFont.Fantasy,
+ }
-class CookiePolicy(Base):
+def _set_user_stylesheet(settings):
+ """Set the generated user-stylesheet."""
+ stylesheet = shared.get_user_stylesheet().encode('utf-8')
+ url = urlutils.data_url('text/css;charset=utf-8', stylesheet)
+ settings.setUserStyleSheetUrl(url)
- """The ThirdPartyCookiePolicy setting is different from other settings."""
- MAPPING = {
+def _set_cookie_accept_policy(settings):
+ """Update the content.cookies.accept setting."""
+ mapping = {
'all': QWebSettings.AlwaysAllowThirdPartyCookies,
'no-3rdparty': QWebSettings.AlwaysBlockThirdPartyCookies,
'never': QWebSettings.AlwaysBlockThirdPartyCookies,
'no-unknown-3rdparty': QWebSettings.AllowThirdPartyWithExistingCookies,
}
-
- def _set(self, value, settings=None):
- for obj in self._get_settings(settings):
- obj.setThirdPartyCookiePolicy(self.MAPPING[value])
+ value = config.val.content.cookies.accept
+ settings.setThirdPartyCookiePolicy(mapping[value])
-def _set_user_stylesheet():
- """Set the generated user-stylesheet."""
- stylesheet = shared.get_user_stylesheet().encode('utf-8')
- url = urlutils.data_url('text/css;charset=utf-8', stylesheet)
- QWebSettings.globalSettings().setUserStyleSheetUrl(url)
+def _set_cache_maximum_pages(settings):
+ """Update the content.cache.maximum_pages setting."""
+ value = config.val.content.cache.maximum_pages
+ settings.setMaximumPagesInCache(value)
def _update_settings(option):
"""Update global settings when qwebsettings changed."""
+ global_settings.update_setting(option)
+
+ settings = QWebSettings.globalSettings()
if option in ['scrollbar.hide', 'content.user_stylesheets']:
- _set_user_stylesheet()
- websettings.update_mappings(MAPPINGS, option)
+ _set_user_stylesheet(settings)
+ elif option == 'content.cookies.accept':
+ _set_cookie_accept_policy(settings)
+ elif option == 'content.cache.maximum_pages':
+ _set_cache_maximum_pages(settings)
def init(_args):
@@ -131,92 +173,20 @@ def init(_args):
QWebSettings.setOfflineStoragePath(
os.path.join(data_path, 'offline-storage'))
- websettings.init_mappings(MAPPINGS)
- _set_user_stylesheet()
+ settings = QWebSettings.globalSettings()
+ _set_user_stylesheet(settings)
+ _set_cookie_accept_policy(settings)
+ _set_cache_maximum_pages(settings)
+
config.instance.changed.connect(_update_settings)
+ global global_settings
+ global_settings = WebKitSettings(QWebSettings.globalSettings())
+ global_settings.init_settings()
+
def shutdown():
"""Disable storage so removing tmpdir will work."""
QWebSettings.setIconDatabasePath('')
QWebSettings.setOfflineWebApplicationCachePath('')
QWebSettings.globalSettings().setLocalStoragePath('')
-
-
-MAPPINGS = {
- 'content.images':
- Attribute(QWebSettings.AutoLoadImages),
- 'content.javascript.enabled':
- Attribute(QWebSettings.JavascriptEnabled),
- 'content.javascript.can_open_tabs_automatically':
- Attribute(QWebSettings.JavascriptCanOpenWindows),
- 'content.javascript.can_close_tabs':
- Attribute(QWebSettings.JavascriptCanCloseWindows),
- 'content.javascript.can_access_clipboard':
- Attribute(QWebSettings.JavascriptCanAccessClipboard),
- 'content.plugins':
- Attribute(QWebSettings.PluginsEnabled),
- 'content.webgl':
- Attribute(QWebSettings.WebGLEnabled),
- 'content.hyperlink_auditing':
- Attribute(QWebSettings.HyperlinkAuditingEnabled),
- 'content.local_content_can_access_remote_urls':
- Attribute(QWebSettings.LocalContentCanAccessRemoteUrls),
- 'content.local_content_can_access_file_urls':
- Attribute(QWebSettings.LocalContentCanAccessFileUrls),
- 'content.cookies.accept':
- CookiePolicy(),
- 'content.dns_prefetch':
- Attribute(QWebSettings.DnsPrefetchEnabled),
- 'content.frame_flattening':
- Attribute(QWebSettings.FrameFlatteningEnabled),
- 'content.cache.appcache':
- Attribute(QWebSettings.OfflineWebApplicationCacheEnabled),
- 'content.local_storage':
- Attribute(QWebSettings.LocalStorageEnabled,
- QWebSettings.OfflineStorageDatabaseEnabled),
- 'content.cache.maximum_pages':
- StaticSetter(QWebSettings.setMaximumPagesInCache),
- 'content.developer_extras':
- Attribute(QWebSettings.DeveloperExtrasEnabled),
- 'content.print_element_backgrounds':
- Attribute(QWebSettings.PrintElementBackgrounds),
- 'content.xss_auditing':
- Attribute(QWebSettings.XSSAuditingEnabled),
- 'content.default_encoding':
- Setter(QWebSettings.setDefaultTextEncoding),
- # content.user_stylesheets is handled separately
-
- 'input.spatial_navigation':
- Attribute(QWebSettings.SpatialNavigationEnabled),
- 'input.links_included_in_focus_chain':
- Attribute(QWebSettings.LinksIncludedInFocusChain),
-
- 'fonts.web.family.standard':
- FontFamilySetter(QWebSettings.StandardFont),
- 'fonts.web.family.fixed':
- FontFamilySetter(QWebSettings.FixedFont),
- 'fonts.web.family.serif':
- FontFamilySetter(QWebSettings.SerifFont),
- 'fonts.web.family.sans_serif':
- FontFamilySetter(QWebSettings.SansSerifFont),
- 'fonts.web.family.cursive':
- FontFamilySetter(QWebSettings.CursiveFont),
- 'fonts.web.family.fantasy':
- FontFamilySetter(QWebSettings.FantasyFont),
- 'fonts.web.size.minimum':
- Setter(QWebSettings.setFontSize, args=[QWebSettings.MinimumFontSize]),
- 'fonts.web.size.minimum_logical':
- Setter(QWebSettings.setFontSize,
- args=[QWebSettings.MinimumLogicalFontSize]),
- 'fonts.web.size.default':
- Setter(QWebSettings.setFontSize, args=[QWebSettings.DefaultFontSize]),
- 'fonts.web.size.default_fixed':
- Setter(QWebSettings.setFontSize,
- args=[QWebSettings.DefaultFixedFontSize]),
-
- 'zoom.text_only':
- Attribute(QWebSettings.ZoomTextOnly),
- 'scrolling.smooth':
- Attribute(QWebSettings.ScrollAnimatorEnabled),
-}
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 9395630db..17757a761 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -30,13 +30,14 @@ import pygments.formatters
import sip
from PyQt5.QtCore import (pyqtSlot, Qt, QEvent, QUrl, QPoint, QTimer, QSizeF,
QSize)
-from PyQt5.QtGui import QKeyEvent
+from PyQt5.QtGui import QKeyEvent, QIcon
from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtPrintSupport import QPrinter
-from qutebrowser.browser import browsertab
-from qutebrowser.browser.webkit import webview, tabhistory, webkitelem
+from qutebrowser.browser import browsertab, shared
+from qutebrowser.browser.webkit import (webview, tabhistory, webkitelem,
+ webkitsettings)
from qutebrowser.utils import qtutils, objreg, usertypes, utils, log, debug
@@ -146,8 +147,17 @@ class WebKitSearch(browsertab.AbstractSearch):
def search(self, text, *, ignore_case='never', reverse=False,
result_cb=None):
- self.search_displayed = True
+ # Don't go to next entry on duplicate search
+ if self.text == text and self.search_displayed:
+ log.webview.debug("Ignoring duplicate search request"
+ " for {}".format(text))
+ return
+
+ # Clear old search results, this is done automatically on QtWebEngine.
+ self.clear()
+
self.text = text
+ self.search_displayed = True
self._flags = QWebPage.FindWrapsAroundDocument
if self._is_case_sensitive(ignore_case):
self._flags |= QWebPage.FindCaseSensitively
@@ -205,8 +215,8 @@ class WebKitCaret(browsertab.AbstractCaret):
self._widget.page().currentFrame().evaluateJavaScript(
utils.read_file('javascript/position_caret.js'))
- @pyqtSlot()
- def _on_mode_left(self):
+ @pyqtSlot(usertypes.KeyMode)
+ def _on_mode_left(self, _mode):
settings = self._widget.settings()
if settings.testAttribute(QWebSettings.CaretBrowsingEnabled):
if self.selection_enabled and self._widget.hasSelection():
@@ -517,7 +527,8 @@ class WebKitHistory(browsertab.AbstractHistory):
return self._history.itemAt(i)
def _go_to_item(self, item):
- return self._history.goToItem(item)
+ self._tab.predicted_navigation.emit(item.url())
+ self._history.goToItem(item)
def serialize(self):
return qtutils.serialize(self._history)
@@ -526,6 +537,9 @@ class WebKitHistory(browsertab.AbstractHistory):
return qtutils.deserialize(data, self._history)
def load_items(self, items):
+ if items:
+ self._tab.predicted_navigation.emit(items[-1].url)
+
stream, _data, user_data = tabhistory.serialize(items)
qtutils.deserialize_stream(stream, self._history)
for i, data in enumerate(user_data):
@@ -644,6 +658,8 @@ class WebKitTab(browsertab.AbstractTab):
self.printing = WebKitPrinting()
self.elements = WebKitElements(tab=self)
self.action = WebKitAction(tab=self)
+ # We're assigning settings in _set_widget
+ self.settings = webkitsettings.WebKitSettings(settings=None)
self._set_widget(widget)
self._connect_signals()
self.backend = usertypes.Backend.QtWebKit
@@ -655,8 +671,8 @@ class WebKitTab(browsertab.AbstractTab):
settings = widget.settings()
settings.setAttribute(QWebSettings.PrivateBrowsingEnabled, True)
- def openurl(self, url):
- self._openurl_prepare(url)
+ def openurl(self, url, *, predict=True):
+ self._openurl_prepare(url, predict=predict)
self._widget.openurl(url)
def url(self, requested=False):
@@ -730,6 +746,8 @@ class WebKitTab(browsertab.AbstractTab):
def _on_load_started(self):
super()._on_load_started()
self.networkaccessmanager().netrc_used = False
+ # Make sure the icon is cleared when navigating to a page without one.
+ self.icon_changed.emit(QIcon())
@pyqtSlot()
def _on_frame_load_finished(self):
@@ -761,6 +779,31 @@ class WebKitTab(browsertab.AbstractTab):
def _on_contents_size_changed(self, size):
self.contents_size_changed.emit(QSizeF(size))
+ @pyqtSlot(usertypes.NavigationRequest)
+ def _on_navigation_request(self, navigation):
+ super()._on_navigation_request(navigation)
+ if not navigation.accepted:
+ return
+
+ log.webview.debug("target {} override {}".format(
+ self.data.open_target, self.data.override_target))
+
+ if self.data.override_target is not None:
+ target = self.data.override_target
+ self.data.override_target = None
+ else:
+ target = self.data.open_target
+
+ if (navigation.navigation_type == navigation.Type.link_clicked and
+ target != usertypes.ClickTarget.normal):
+ tab = shared.get_tab(self.win_id, target)
+ tab.openurl(navigation.url)
+ self.data.open_target = usertypes.ClickTarget.normal
+ navigation.accepted = False
+
+ if navigation.is_main_frame:
+ self.settings.update_for_url(navigation.url)
+
def _connect_signals(self):
view = self._widget
page = view.page()
@@ -779,6 +822,7 @@ class WebKitTab(browsertab.AbstractTab):
page.frameCreated.connect(self._on_frame_created)
frame.contentsSizeChanged.connect(self._on_contents_size_changed)
frame.initialLayoutCompleted.connect(self._on_history_trigger)
+ page.navigation_request.connect(self._on_navigation_request)
def event_target(self):
return self._widget
diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py
index 679ec2d88..7b0a5caf5 100644
--- a/qutebrowser/browser/webkit/webpage.py
+++ b/qutebrowser/browser/webkit/webpage.py
@@ -22,6 +22,7 @@
import html
import functools
+import sip
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QUrl, QPoint
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
@@ -33,8 +34,7 @@ from qutebrowser.config import config
from qutebrowser.browser import pdfjs, shared
from qutebrowser.browser.webkit import http
from qutebrowser.browser.webkit.network import networkmanager
-from qutebrowser.utils import (message, usertypes, log, jinja, objreg, debug,
- urlutils)
+from qutebrowser.utils import message, usertypes, log, jinja, objreg
class BrowserPage(QWebPage):
@@ -54,10 +54,12 @@ class BrowserPage(QWebPage):
shutting_down: Emitted when the page is currently shutting down.
reloading: Emitted before a web page reloads.
arg: The URL which gets reloaded.
+ navigation_request: Emitted on acceptNavigationRequest.
"""
shutting_down = pyqtSignal()
reloading = pyqtSignal(QUrl)
+ navigation_request = pyqtSignal(usertypes.NavigationRequest)
def __init__(self, win_id, tab_id, tabdata, private, parent=None):
super().__init__(parent)
@@ -70,7 +72,6 @@ class BrowserPage(QWebPage):
}
self._ignore_load_started = False
self.error_occurred = False
- self.open_target = usertypes.ClickTarget.normal
self._networkmanager = networkmanager.NetworkManager(
win_id=win_id, tab_id=tab_id, private=private, parent=self)
self.setNetworkAccessManager(self._networkmanager)
@@ -302,6 +303,10 @@ class BrowserPage(QWebPage):
Args:
frame: The QWebFrame to inject the user scripts into.
"""
+ if sip.isdeleted(frame):
+ log.greasemonkey.debug("_inject_userjs called for deleted frame!")
+ return
+
url = frame.url()
if url.isEmpty():
url = frame.requestedUrl()
@@ -474,7 +479,7 @@ class BrowserPage(QWebPage):
source, line, msg)
def acceptNavigationRequest(self,
- _frame: QWebFrame,
+ frame: QWebFrame,
request: QNetworkRequest,
typ: QWebPage.NavigationType):
"""Override acceptNavigationRequest to handle clicked links.
@@ -486,36 +491,27 @@ class BrowserPage(QWebPage):
Checks if it should open it in a tab (middle-click or control) or not,
and then conditionally opens the URL here or in another tab/window.
"""
- url = request.url()
- log.webview.debug("navigation request: url {}, type {}, "
- "target {} override {}".format(
- url.toDisplayString(),
- debug.qenum_key(QWebPage, typ),
- self.open_target,
- self._tabdata.override_target))
-
- if self._tabdata.override_target is not None:
- target = self._tabdata.override_target
- self._tabdata.override_target = None
- else:
- target = self.open_target
-
- if typ == QWebPage.NavigationTypeReload:
- self.reloading.emit(url)
- return True
- elif typ != QWebPage.NavigationTypeLinkClicked:
- return True
-
- if not url.isValid():
- msg = urlutils.get_errstring(url, "Invalid link clicked")
- message.error(msg)
- self.open_target = usertypes.ClickTarget.normal
- return False
+ type_map = {
+ QWebPage.NavigationTypeLinkClicked:
+ usertypes.NavigationRequest.Type.link_clicked,
+ QWebPage.NavigationTypeFormSubmitted:
+ usertypes.NavigationRequest.Type.form_submitted,
+ QWebPage.NavigationTypeFormResubmitted:
+ usertypes.NavigationRequest.Type.form_resubmitted,
+ QWebPage.NavigationTypeBackOrForward:
+ usertypes.NavigationRequest.Type.back_forward,
+ QWebPage.NavigationTypeReload:
+ usertypes.NavigationRequest.Type.reloaded,
+ QWebPage.NavigationTypeOther:
+ usertypes.NavigationRequest.Type.other,
+ }
+ is_main_frame = frame is self.mainFrame()
+ navigation = usertypes.NavigationRequest(url=request.url(),
+ navigation_type=type_map[typ],
+ is_main_frame=is_main_frame)
- if target == usertypes.ClickTarget.normal:
- return True
+ if navigation.navigation_type == navigation.Type.reloaded:
+ self.reloading.emit(navigation.url)
- tab = shared.get_tab(self._win_id, target)
- tab.openurl(url)
- self.open_target = usertypes.ClickTarget.normal
- return False
+ self.navigation_request.emit(navigation)
+ return navigation.accepted
diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py
index 942e7265c..79da9778c 100644
--- a/qutebrowser/browser/webkit/webview.py
+++ b/qutebrowser/browser/webkit/webview.py
@@ -262,10 +262,10 @@ class WebView(QWebView):
target = usertypes.ClickTarget.tab_bg
else:
target = usertypes.ClickTarget.tab
- self.page().open_target = target
+ self._tabdata.open_target = target
log.mouse.debug("Ctrl/Middle click, setting target: {}".format(
target))
else:
- self.page().open_target = usertypes.ClickTarget.normal
+ self._tabdata.open_target = usertypes.ClickTarget.normal
log.mouse.debug("Normal click, setting normal target")
super().mousePressEvent(e)
diff --git a/qutebrowser/commands/__init__.py b/qutebrowser/commands/__init__.py
index 0bbc9852b..6ba8a9ae3 100644
--- a/qutebrowser/commands/__init__.py
+++ b/qutebrowser/commands/__init__.py
@@ -26,6 +26,7 @@ For command arguments, there are also some variables you can use:
- `{url}` expands to the URL of the current page
- `{url:pretty}` expands to the URL in decoded format
+- `{url:host}` expands to the host part of the URL
- `{clipboard}` expands to the clipboard contents
- `{primary}` expands to the primary selection contents
diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py
index 16c790eca..c3f5d87a1 100644
--- a/qutebrowser/commands/runners.py
+++ b/qutebrowser/commands/runners.py
@@ -63,9 +63,13 @@ def replace_variables(win_id, arglist):
QUrl.FullyEncoded | QUrl.RemovePassword),
'url:pretty': lambda: _current_url(tabbed_browser).toString(
QUrl.DecodeReserved | QUrl.RemovePassword),
+ 'url:host': lambda: _current_url(tabbed_browser).host(),
'clipboard': utils.get_clipboard,
'primary': lambda: utils.get_clipboard(selection=True),
}
+ for key in list(variables):
+ modified_key = '{' + key + '}'
+ variables[modified_key] = lambda x=modified_key: x
values = {}
args = []
tabbed_browser = objreg.get('tabbed-browser', scope='window',
diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py
index 09b80ed12..8506f3aa7 100644
--- a/qutebrowser/completion/completer.py
+++ b/qutebrowser/completion/completer.py
@@ -60,7 +60,7 @@ class Completer(QObject):
self._timer.setSingleShot(True)
self._timer.setInterval(0)
self._timer.timeout.connect(self._update_completion)
- self._last_cursor_pos = None
+ self._last_cursor_pos = -1
self._last_text = None
self._last_completion_func = None
self._cmd.update_completion.connect(self.schedule_completion_update)
diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py
index c433dbc12..b462442a0 100644
--- a/qutebrowser/completion/models/configmodel.py
+++ b/qutebrowser/completion/models/configmodel.py
@@ -22,13 +22,15 @@
from qutebrowser.config import configdata, configexc
from qutebrowser.completion.models import completionmodel, listcategory, util
from qutebrowser.commands import runners, cmdexc
+from qutebrowser.keyinput import keyutils
def option(*, info):
"""A CompletionModel filled with settings and their descriptions."""
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
options = ((opt.name, opt.description, info.config.get_str(opt.name))
- for opt in configdata.DATA.values())
+ for opt in configdata.DATA.values()
+ if not opt.no_autoconfig)
model.add_category(listcategory.ListCategory("Options", options))
return model
@@ -36,8 +38,10 @@ def option(*, info):
def customized_option(*, info):
"""A CompletionModel filled with set settings and their descriptions."""
model = completionmodel.CompletionModel(column_widths=(20, 70, 10))
- options = ((opt.name, opt.description, info.config.get_str(opt.name))
- for opt, _value in info.config)
+ options = ((values.opt.name, values.opt.description,
+ info.config.get_str(values.opt.name))
+ for values in info.config
+ if values)
model.add_category(listcategory.ListCategory("Customized options",
options))
return model
@@ -71,16 +75,16 @@ def value(optname, *_values, info):
return model
-def bind(key, *, info):
- """A CompletionModel filled with all bindable commands and descriptions.
-
- Args:
- key: the key being bound.
- """
- model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
+def _bind_current_default(key, info):
+ """Get current/default data for the given key."""
data = []
+ try:
+ seq = keyutils.KeySequence.parse(key)
+ except keyutils.KeyParseError as e:
+ data.append(('', str(e), key))
+ return data
- cmd_text = info.keyconf.get_command(key, 'normal')
+ cmd_text = info.keyconf.get_command(seq, 'normal')
if cmd_text:
parser = runners.CommandParser()
try:
@@ -90,12 +94,24 @@ def bind(key, *, info):
else:
data.append((cmd_text, '(Current) {}'.format(cmd.desc), key))
- cmd_text = info.keyconf.get_command(key, 'normal', default=True)
+ cmd_text = info.keyconf.get_command(seq, 'normal', default=True)
if cmd_text:
parser = runners.CommandParser()
cmd = parser.parse(cmd_text).cmd
data.append((cmd_text, '(Default) {}'.format(cmd.desc), key))
+ return data
+
+
+def bind(key, *, info):
+ """A CompletionModel filled with all bindable commands and descriptions.
+
+ Args:
+ key: the key being bound.
+ """
+ model = completionmodel.CompletionModel(column_widths=(20, 60, 20))
+ data = _bind_current_default(key, info)
+
if data:
model.add_category(listcategory.ListCategory("Current/Default", data))
diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py
index a07f78143..60f801492 100644
--- a/qutebrowser/completion/models/histcategory.py
+++ b/qutebrowser/completion/models/histcategory.py
@@ -80,7 +80,7 @@ class HistoryCategory(QSqlQueryModel):
for i in range(len(words)))
# replace ' in timestamp-format to avoid breaking the query
- timestamp_format = config.val.completion.timestamp_format
+ timestamp_format = config.val.completion.timestamp_format or ''
timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')"
.format(timestamp_format.replace("'", "`")))
diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py
index 049d89295..8606bbf75 100644
--- a/qutebrowser/completion/models/miscmodels.py
+++ b/qutebrowser/completion/models/miscmodels.py
@@ -117,11 +117,11 @@ def _buffer(skip_win_id=None):
if tabbed_browser.shutting_down:
continue
tabs = []
- for idx in range(tabbed_browser.count()):
- tab = tabbed_browser.widget(idx)
+ for idx in range(tabbed_browser.widget.count()):
+ tab = tabbed_browser.widget.widget(idx)
tabs.append(("{}/{}".format(win_id, idx + 1),
tab.url().toDisplayString(),
- tabbed_browser.page_title(idx)))
+ tabbed_browser.widget.page_title(idx)))
cat = listcategory.ListCategory("{}".format(win_id), tabs,
delete_func=delete_buffer)
model.add_category(cat)
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
index f30acb8ca..7204fb8f3 100644
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -25,9 +25,10 @@ import functools
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject
-from qutebrowser.config import configdata, configexc
+from qutebrowser.config import configdata, configexc, configutils
from qutebrowser.utils import utils, log, jinja
from qutebrowser.misc import objects
+from qutebrowser.keyinput import keyutils
# An easy way to access the config from other code via config.val.foo
val = None
@@ -37,6 +38,9 @@ key_instance = None
# Keeping track of all change filters to validate them later.
change_filters = []
+# Sentinel
+UNSET = object()
+
class change_filter: # noqa: N801,N806 pylint: disable=invalid-name
@@ -132,20 +136,18 @@ class KeyConfig:
def __init__(self, config):
self._config = config
- def _prepare(self, key, mode):
- """Make sure the given mode exists and normalize the key."""
+ def _validate(self, key, mode):
+ """Validate the given key and mode."""
+ # Catch old usage of this code
+ assert isinstance(key, keyutils.KeySequence), key
if mode not in configdata.DATA['bindings.default'].default:
raise configexc.KeybindingError("Invalid mode {}!".format(mode))
- if utils.is_special_key(key):
- # <Ctrl-t>, <ctrl-T>, and <ctrl-t> should be considered equivalent
- return utils.normalize_keystr(key)
- return key
def get_bindings_for(self, mode):
"""Get the combined bindings for the given mode."""
bindings = dict(val.bindings.default[mode])
for key, binding in val.bindings.commands[mode].items():
- if binding is None:
+ if not binding:
bindings.pop(key, None)
else:
bindings[key] = binding
@@ -155,20 +157,20 @@ class KeyConfig:
"""Get a dict of commands to a list of bindings for the mode."""
cmd_to_keys = {}
bindings = self.get_bindings_for(mode)
- for key, full_cmd in sorted(bindings.items()):
+ for seq, full_cmd in sorted(bindings.items()):
for cmd in full_cmd.split(';;'):
cmd = cmd.strip()
cmd_to_keys.setdefault(cmd, [])
- # put special bindings last
- if utils.is_special_key(key):
- cmd_to_keys[cmd].append(key)
+ # Put bindings involving modifiers last
+ if any(info.modifiers for info in seq):
+ cmd_to_keys[cmd].append(str(seq))
else:
- cmd_to_keys[cmd].insert(0, key)
+ cmd_to_keys[cmd].insert(0, str(seq))
return cmd_to_keys
def get_command(self, key, mode, default=False):
"""Get the command for a given key (or None)."""
- key = self._prepare(key, mode)
+ self._validate(key, mode)
if default:
bindings = dict(val.bindings.default[mode])
else:
@@ -182,23 +184,23 @@ class KeyConfig:
"Can't add binding '{}' with empty command in {} "
'mode'.format(key, mode))
- key = self._prepare(key, mode)
+ self._validate(key, mode)
log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format(
key, command, mode))
- bindings = self._config.get_obj('bindings.commands')
+ bindings = self._config.get_mutable_obj('bindings.commands')
if mode not in bindings:
bindings[mode] = {}
- bindings[mode][key] = command
+ bindings[mode][str(key)] = command
self._config.update_mutables(save_yaml=save_yaml)
def bind_default(self, key, *, mode='normal', save_yaml=False):
"""Restore a default keybinding."""
- key = self._prepare(key, mode)
+ self._validate(key, mode)
- bindings_commands = self._config.get_obj('bindings.commands')
+ bindings_commands = self._config.get_mutable_obj('bindings.commands')
try:
- del bindings_commands[mode][key]
+ del bindings_commands[mode][str(key)]
except KeyError:
raise configexc.KeybindingError(
"Can't find binding '{}' in {} mode".format(key, mode))
@@ -206,18 +208,18 @@ class KeyConfig:
def unbind(self, key, *, mode='normal', save_yaml=False):
"""Unbind the given key in the given mode."""
- key = self._prepare(key, mode)
+ self._validate(key, mode)
- bindings_commands = self._config.get_obj('bindings.commands')
+ bindings_commands = self._config.get_mutable_obj('bindings.commands')
if val.bindings.commands[mode].get(key, None) is not None:
# In custom bindings -> remove it
- del bindings_commands[mode][key]
+ del bindings_commands[mode][str(key)]
elif key in val.bindings.default[mode]:
# In default bindings -> shadow it with None
if mode not in bindings_commands:
bindings_commands[mode] = {}
- bindings_commands[mode][key] = None
+ bindings_commands[mode][str(key)] = None
else:
raise configexc.KeybindingError(
"Can't find binding '{}' in {} mode".format(key, mode))
@@ -229,8 +231,12 @@ class Config(QObject):
"""Main config object.
+ Class attributes:
+ MUTABLE_TYPES: Types returned from the config which could potentially
+ be mutated.
+
Attributes:
- _values: A dict mapping setting names to their values.
+ _values: A dict mapping setting names to configutils.Values objects.
_mutables: A dictionary of mutable objects to be checked for changes.
_yaml: A YamlConfig object or None.
@@ -238,19 +244,25 @@ class Config(QObject):
changed: Emitted with the option name when an option changed.
"""
+ MUTABLE_TYPES = (dict, list)
changed = pyqtSignal(str)
def __init__(self, yaml_config, parent=None):
super().__init__(parent)
self.changed.connect(_render_stylesheet.cache_clear)
- self._values = {}
self._mutables = {}
self._yaml = yaml_config
+ self._init_values()
+
+ def _init_values(self):
+ """Populate the self._values dict."""
+ self._values = {}
+ for name, opt in configdata.DATA.items():
+ self._values[name] = configutils.Values(opt)
def __iter__(self):
- """Iterate over Option, value tuples."""
- for name, value in sorted(self._values.items()):
- yield (self.get_opt(name), value)
+ """Iterate over configutils.Values items."""
+ yield from self._values.values()
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
@@ -260,24 +272,32 @@ class Config(QObject):
"""
self._yaml.init_save_manager(save_manager)
- def _set_value(self, opt, value):
+ def _set_value(self, opt, value, pattern=None):
"""Set the given option to the given value."""
if not isinstance(objects.backend, objects.NoBackend):
if objects.backend not in opt.backends:
raise configexc.BackendError(opt.name, objects.backend)
opt.typ.to_py(value) # for validation
- self._values[opt.name] = opt.typ.from_obj(value)
+
+ self._values[opt.name].add(opt.typ.from_obj(value), pattern)
self.changed.emit(opt.name)
log.config.debug("Config option changed: {} = {}".format(
opt.name, value))
+ def _check_yaml(self, opt, save_yaml):
+ """Make sure the given option may be set in autoconfig.yml."""
+ if save_yaml and opt.no_autoconfig:
+ raise configexc.NoAutoconfigError(opt.name)
+
def read_yaml(self):
"""Read the YAML settings from self._yaml."""
self._yaml.load()
- for name, value in self._yaml:
- self._set_value(self.get_opt(name), value)
+ for values in self._yaml:
+ for scoped in values:
+ self._set_value(values.opt, scoped.value,
+ pattern=scoped.pattern)
def get_opt(self, name):
"""Get a configdata.Option object for the given setting."""
@@ -290,77 +310,115 @@ class Config(QObject):
name, deleted=deleted, renamed=renamed)
raise exception from None
- def get(self, name):
+ def get(self, name, url=None):
"""Get the given setting converted for Python code."""
opt = self.get_opt(name)
- obj = self.get_obj(name, mutable=False)
+ obj = self.get_obj(name, url=url)
return opt.typ.to_py(obj)
- def get_obj(self, name, *, mutable=True):
+ def _maybe_copy(self, value):
+ """Copy the value if it could potentially be mutated."""
+ if isinstance(value, self.MUTABLE_TYPES):
+ # For mutable objects, create a copy so we don't accidentally
+ # mutate the config's internal value.
+ return copy.deepcopy(value)
+ else:
+ # Shouldn't be mutable (and thus hashable)
+ assert value.__hash__ is not None, value
+ return value
+
+ def get_obj(self, name, *, url=None):
"""Get the given setting as object (for YAML/config.py).
- If mutable=True is set, watch the returned object for mutations.
+ Note that the returned values are not watched for mutation.
+ If a URL is given, return the value which should be used for that URL.
"""
- opt = self.get_opt(name)
- obj = None
+ self.get_opt(name) # To make sure it exists
+ value = self._values[name].get_for_url(url)
+ return self._maybe_copy(value)
+
+ def get_obj_for_pattern(self, name, *, pattern):
+ """Get the given setting as object (for YAML/config.py).
+
+ This gets the overridden value for a given pattern, or
+ configutils.UNSET if no such override exists.
+ """
+ self.get_opt(name) # To make sure it exists
+ value = self._values[name].get_for_pattern(pattern, fallback=False)
+ return self._maybe_copy(value)
+
+ def get_mutable_obj(self, name, *, pattern=None):
+ """Get an object which can be mutated, e.g. in a config.py.
+
+ If a pattern is given, return the value for that pattern.
+ Note that it's impossible to get a mutable object for an URL as we
+ wouldn't know what pattern to apply.
+ """
+ self.get_opt(name) # To make sure it exists
+
# If we allow mutation, there is a chance that prior mutations already
# entered the mutable dictionary and thus further copies are unneeded
# until update_mutables() is called
- if name in self._mutables and mutable:
+ if name in self._mutables:
_copy, obj = self._mutables[name]
- # Otherwise, we return a copy of the value stored internally, so the
- # internal value can never be changed by mutating the object returned.
- else:
- obj = copy.deepcopy(self._values.get(name, opt.default))
- # Then we watch the returned object for changes.
- if isinstance(obj, (dict, list)):
- if mutable:
- self._mutables[name] = (copy.deepcopy(obj), obj)
- else:
- # Shouldn't be mutable (and thus hashable)
- assert obj.__hash__ is not None, obj
- return obj
+ return obj
+
+ value = self._values[name].get_for_pattern(pattern)
+ copy_value = self._maybe_copy(value)
+
+ # Watch the returned object for changes if it's mutable.
+ if isinstance(copy_value, self.MUTABLE_TYPES):
+ self._mutables[name] = (value, copy_value) # old, new
+
+ return copy_value
- def get_str(self, name):
- """Get the given setting as string."""
+ def get_str(self, name, *, pattern=None):
+ """Get the given setting as string.
+
+ If a pattern is given, get the setting for the given pattern or
+ configutils.UNSET.
+ """
opt = self.get_opt(name)
- value = self._values.get(name, opt.default)
+ values = self._values[name]
+ value = values.get_for_pattern(pattern)
return opt.typ.to_str(value)
- def set_obj(self, name, value, *, save_yaml=False):
+ def set_obj(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a YAML/config.py object.
If save_yaml=True is given, store the new value to YAML.
"""
- self._set_value(self.get_opt(name), value)
+ opt = self.get_opt(name)
+ self._check_yaml(opt, save_yaml)
+ self._set_value(opt, value, pattern=pattern)
if save_yaml:
- self._yaml[name] = value
+ self._yaml.set_obj(name, value, pattern=pattern)
- def set_str(self, name, value, *, save_yaml=False):
+ def set_str(self, name, value, *, pattern=None, save_yaml=False):
"""Set the given setting from a string.
If save_yaml=True is given, store the new value to YAML.
"""
opt = self.get_opt(name)
+ self._check_yaml(opt, save_yaml)
converted = opt.typ.from_str(value)
log.config.debug("Setting {} (type {}) to {!r} (converted from {!r})"
.format(name, opt.typ.__class__.__name__, converted,
value))
- self._set_value(opt, converted)
+ self._set_value(opt, converted, pattern=pattern)
if save_yaml:
- self._yaml[name] = converted
+ self._yaml.set_obj(name, converted, pattern=pattern)
- def unset(self, name, *, save_yaml=False):
+ def unset(self, name, *, save_yaml=False, pattern=None):
"""Set the given setting back to its default."""
- self.get_opt(name)
- try:
- del self._values[name]
- except KeyError:
- return
- self.changed.emit(name)
+ opt = self.get_opt(name)
+ self._check_yaml(opt, save_yaml)
+ changed = self._values[name].remove(pattern)
+ if changed:
+ self.changed.emit(name)
if save_yaml:
- self._yaml.unset(name)
+ self._yaml.unset(name, pattern=pattern)
def clear(self, *, save_yaml=False):
"""Clear all settings in the config.
@@ -368,10 +426,10 @@ class Config(QObject):
If save_yaml=True is given, also remove all customization from the YAML
file.
"""
- old_values = self._values
- self._values = {}
- for name in old_values:
- self.changed.emit(name)
+ for name, values in self._values.items():
+ if values:
+ values.clear()
+ self.changed.emit(name)
if save_yaml:
self._yaml.clear()
@@ -397,13 +455,15 @@ class Config(QObject):
Return:
The changed config part as string.
"""
- lines = []
- for opt, value in self:
- str_value = opt.typ.to_str(value)
- lines.append('{} = {}'.format(opt.name, str_value))
- if not lines:
- lines = ['<Default configuration>']
- return '\n'.join(lines)
+ blocks = []
+ for values in sorted(self, key=lambda v: v.opt.name):
+ if values:
+ blocks.append(str(values))
+
+ if not blocks:
+ return '<Default configuration>'
+
+ return '\n'.join(blocks)
class ConfigContainer:
@@ -415,16 +475,21 @@ class ConfigContainer:
_prefix: The __getattr__ chain leading up to this object.
_configapi: If given, get values suitable for config.py and
add errors to the given ConfigAPI object.
+ _pattern: The URL pattern to be used.
"""
- def __init__(self, config, configapi=None, prefix=''):
+ def __init__(self, config, configapi=None, prefix='', pattern=None):
self._config = config
self._prefix = prefix
self._configapi = configapi
+ self._pattern = pattern
+ if configapi is None and pattern is not None:
+ raise TypeError("Can't use pattern without configapi!")
def __repr__(self):
return utils.get_repr(self, constructor=True, config=self._config,
- configapi=self._configapi, prefix=self._prefix)
+ configapi=self._configapi, prefix=self._prefix,
+ pattern=self._pattern)
@contextlib.contextmanager
def _handle_error(self, action, name):
@@ -452,7 +517,7 @@ class ConfigContainer:
if configdata.is_valid_prefix(name):
return ConfigContainer(config=self._config,
configapi=self._configapi,
- prefix=name)
+ prefix=name, pattern=self._pattern)
with self._handle_error('getting', name):
if self._configapi is None:
@@ -460,7 +525,8 @@ class ConfigContainer:
return self._config.get(name)
else:
# access from config.py
- return self._config.get_obj(name)
+ return self._config.get_mutable_obj(
+ name, pattern=self._pattern)
def __setattr__(self, attr, value):
"""Set the given option in the config."""
@@ -470,7 +536,7 @@ class ConfigContainer:
name = self._join(attr)
with self._handle_error('setting', name):
- self._config.set_obj(name, value)
+ self._config.set_obj(name, value, pattern=self._pattern)
def _join(self, attr):
"""Get the prefix joined with the given attribute."""
diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py
index 8bc2a9ed8..792eacaf0 100644
--- a/qutebrowser/config/configcommands.py
+++ b/qutebrowser/config/configcommands.py
@@ -26,9 +26,10 @@ from PyQt5.QtCore import QUrl
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.completion.models import configmodel
-from qutebrowser.utils import objreg, utils, message, standarddir
+from qutebrowser.utils import objreg, message, standarddir, urlmatch
from qutebrowser.config import configtypes, configexc, configfiles, configdata
from qutebrowser.misc import editor
+from qutebrowser.keyinput import keyutils
class ConfigCommands:
@@ -47,17 +48,41 @@ class ConfigCommands:
except configexc.Error as e:
raise cmdexc.CommandError(str(e))
- def _print_value(self, option):
+ def _parse_pattern(self, pattern):
+ """Parse a pattern string argument to a pattern."""
+ if pattern is None:
+ return None
+
+ try:
+ return urlmatch.UrlPattern(pattern)
+ except urlmatch.ParseError as e:
+ raise cmdexc.CommandError("Error while parsing {}: {}"
+ .format(pattern, str(e)))
+
+ def _parse_key(self, key):
+ """Parse a key argument."""
+ try:
+ return keyutils.KeySequence.parse(key)
+ except keyutils.KeyParseError as e:
+ raise cmdexc.CommandError(str(e))
+
+ def _print_value(self, option, pattern):
"""Print the value of the given option."""
with self._handle_config_error():
- value = self._config.get_str(option)
- message.info("{} = {}".format(option, value))
+ value = self._config.get_str(option, pattern=pattern)
+
+ text = "{} = {}".format(option, value)
+ if pattern is not None:
+ text += " for {}".format(pattern)
+ message.info(text)
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('value', completion=configmodel.value)
@cmdutils.argument('win_id', win_id=True)
- def set(self, win_id, option=None, value=None, temp=False, print_=False):
+ @cmdutils.argument('pattern', flag='u')
+ def set(self, win_id, option=None, value=None, temp=False, print_=False,
+ *, pattern=None):
"""Set an option.
If the option name ends with '?', the value of the option is shown
@@ -69,6 +94,7 @@ class ConfigCommands:
Args:
option: The name of the option.
value: The value to set.
+ pattern: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
@@ -82,8 +108,10 @@ class ConfigCommands:
raise cmdexc.CommandError("Toggling values was moved to the "
":config-cycle command")
+ pattern = self._parse_pattern(pattern)
+
if option.endswith('?') and option != '?':
- self._print_value(option[:-1])
+ self._print_value(option[:-1], pattern=pattern)
return
with self._handle_config_error():
@@ -91,10 +119,11 @@ class ConfigCommands:
raise cmdexc.CommandError("set: The following arguments "
"are required: value")
else:
- self._config.set_str(option, value, save_yaml=not temp)
+ self._config.set_str(option, value, pattern=pattern,
+ save_yaml=not temp)
if print_:
- self._print_value(option)
+ self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands', maxsplit=1,
no_cmd_split=True, no_replace_variables=True)
@@ -108,7 +137,8 @@ class ConfigCommands:
Using :bind without any arguments opens a page showing all keybindings.
Args:
- key: The keychain or special key (inside `<...>`) to bind.
+ key: The keychain to bind. Examples of valid keychains are `gC`,
+ `<Ctrl-X>` or `<Ctrl-C>a`.
command: The command to execute, with optional args.
mode: A comma-separated list of modes to bind the key in
(default: `normal`). See `:help bindings.commands` for the
@@ -121,58 +151,64 @@ class ConfigCommands:
tabbed_browser.openurl(QUrl('qute://bindings'), newtab=True)
return
+ seq = self._parse_key(key)
+
if command is None:
if default:
# :bind --default: Restore default
with self._handle_config_error():
- self._keyconfig.bind_default(key, mode=mode,
+ self._keyconfig.bind_default(seq, mode=mode,
save_yaml=True)
return
# No --default -> print binding
- if utils.is_special_key(key):
- # self._keyconfig.get_command does this, but we also need it
- # normalized for the output below
- key = utils.normalize_keystr(key)
with self._handle_config_error():
- cmd = self._keyconfig.get_command(key, mode)
+ cmd = self._keyconfig.get_command(seq, mode)
if cmd is None:
- message.info("{} is unbound in {} mode".format(key, mode))
+ message.info("{} is unbound in {} mode".format(seq, mode))
else:
message.info("{} is bound to '{}' in {} mode".format(
- key, cmd, mode))
+ seq, cmd, mode))
return
with self._handle_config_error():
- self._keyconfig.bind(key, command, mode=mode, save_yaml=True)
+ self._keyconfig.bind(seq, command, mode=mode, save_yaml=True)
@cmdutils.register(instance='config-commands')
def unbind(self, key, *, mode='normal'):
"""Unbind a keychain.
Args:
- key: The keychain or special key (inside <...>) to unbind.
+ key: The keychain to unbind. See the help for `:bind` for the
+ correct syntax for keychains.
mode: A mode to unbind the key in (default: `normal`).
See `:help bindings.commands` for the available modes.
"""
with self._handle_config_error():
- self._keyconfig.unbind(key, mode=mode, save_yaml=True)
+ self._keyconfig.unbind(self._parse_key(key), mode=mode,
+ save_yaml=True)
@cmdutils.register(instance='config-commands', star_args_optional=True)
@cmdutils.argument('option', completion=configmodel.option)
@cmdutils.argument('values', completion=configmodel.value)
- def config_cycle(self, option, *values, temp=False, print_=False):
+ @cmdutils.argument('pattern', flag='u')
+ def config_cycle(self, option, *values, pattern=None, temp=False,
+ print_=False):
"""Cycle an option between multiple values.
Args:
option: The name of the option.
values: The values to cycle through.
+ pattern: The URL pattern to use.
temp: Set value temporarily until qutebrowser is closed.
print_: Print the value after setting.
"""
+ pattern = self._parse_pattern(pattern)
+
with self._handle_config_error():
opt = self._config.get_opt(option)
- old_value = self._config.get_obj(option, mutable=False)
+ old_value = self._config.get_obj_for_pattern(option,
+ pattern=pattern)
if not values and isinstance(opt.typ, configtypes.Bool):
values = ['true', 'false']
@@ -194,10 +230,11 @@ class ConfigCommands:
value = values[0]
with self._handle_config_error():
- self._config.set_obj(option, value, save_yaml=not temp)
+ self._config.set_obj(option, value, pattern=pattern,
+ save_yaml=not temp)
if print_:
- self._print_value(option)
+ self._print_value(option, pattern=pattern)
@cmdutils.register(instance='config-commands')
@cmdutils.argument('option', completion=configmodel.customized_option)
@@ -291,13 +328,16 @@ class ConfigCommands:
"overwrite!".format(filename))
if defaults:
- options = [(opt, opt.default)
+ options = [(None, opt, opt.default)
for _name, opt in sorted(configdata.DATA.items())]
bindings = dict(configdata.DATA['bindings.default'].default)
commented = True
else:
- options = list(self._config)
- bindings = dict(self._config.get_obj('bindings.commands'))
+ options = []
+ for values in self._config:
+ for scoped in values:
+ options.append((scoped.pattern, values.opt, scoped.value))
+ bindings = dict(self._config.get_mutable_obj('bindings.commands'))
commented = False
writer = configfiles.ConfigPyWriter(options, bindings,
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index 3e0a6d8b1..c617fca14 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -48,7 +48,9 @@ class Option:
backends = attr.ib()
raw_backends = attr.ib()
description = attr.ib()
+ supports_pattern = attr.ib(default=False)
restart = attr.ib(default=False)
+ no_autoconfig = attr.ib(default=False)
@attr.s
@@ -197,7 +199,8 @@ def _read_yaml(yaml_data):
migrations = Migrations()
data = utils.yaml_load(yaml_data)
- keys = {'type', 'default', 'desc', 'backend', 'restart'}
+ keys = {'type', 'default', 'desc', 'backend', 'restart',
+ 'supports_pattern', 'no_autoconfig'}
for name, option in data.items():
if set(option.keys()) == {'renamed'}:
@@ -223,7 +226,10 @@ def _read_yaml(yaml_data):
backends=_parse_yaml_backends(name, backends),
raw_backends=backends if isinstance(backends, dict) else None,
description=option['desc'],
- restart=option.get('restart', False))
+ restart=option.get('restart', False),
+ supports_pattern=option.get('supports_pattern', False),
+ no_autoconfig=option.get('no_autoconfig', False),
+ )
# Make sure no key shadows another.
for key1 in parsed:
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 72450978b..a6a2d5317 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -240,6 +240,7 @@ content.cache.appcache:
default: true
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: >-
Enable support for the HTML 5 web application cache feature.
@@ -298,12 +299,14 @@ content.dns_prefetch:
default: true
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: Try to pre-fetch DNS entries to speed up browsing.
content.frame_flattening:
default: false
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: >-
Expand each subframe to its contents.
@@ -459,12 +462,14 @@ content.host_blocking.whitelist:
content.hyperlink_auditing:
default: false
type: Bool
+ supports_pattern: true
desc: Enable hyperlink auditing (`<a ping>`).
content.images:
default: true
type: Bool
desc: Load images automatically in web pages.
+ supports_pattern: true
content.javascript.alert:
default: true
@@ -474,6 +479,7 @@ content.javascript.alert:
content.javascript.can_access_clipboard:
default: false
type: Bool
+ supports_pattern: true
desc: >-
Allow JavaScript to read from or write to the clipboard.
@@ -484,16 +490,19 @@ content.javascript.can_close_tabs:
default: false
type: Bool
backend: QtWebKit
+ supports_pattern: true
desc: Allow JavaScript to close tabs.
content.javascript.can_open_tabs_automatically:
default: false
type: Bool
+ supports_pattern: true
desc: Allow JavaScript to open new tabs without user interaction.
content.javascript.enabled:
default: true
type: Bool
+ supports_pattern: true
desc: Enable JavaScript.
content.javascript.log:
@@ -536,16 +545,19 @@ content.javascript.prompt:
content.local_content_can_access_remote_urls:
default: false
type: Bool
+ supports_pattern: true
desc: Allow locally loaded documents to access remote URLs.
content.local_content_can_access_file_urls:
default: true
type: Bool
+ supports_pattern: true
desc: Allow locally loaded documents to access other local URLs.
content.local_storage:
default: true
type: Bool
+ supports_pattern: true
desc: Enable support for HTML 5 local storage and Web SQL.
content.media_capture:
@@ -583,6 +595,7 @@ content.pdfjs:
content.plugins:
default: false
type: Bool
+ supports_pattern: true
desc: Enable plugins in Web pages.
content.print_element_backgrounds:
@@ -591,6 +604,7 @@ content.print_element_backgrounds:
backend:
QtWebKit: true
QtWebEngine: Qt 5.8
+ supports_pattern: true
desc: >-
Draw the background color and images also when the page is printed.
@@ -631,11 +645,13 @@ content.user_stylesheets:
content.webgl:
default: true
type: Bool
+ supports_pattern: true
desc: Enable WebGL.
content.xss_auditing:
type: Bool
default: false
+ supports_pattern: true
desc: >-
Monitor load requests for cross-site scripting attempts.
@@ -965,6 +981,11 @@ input.insert_mode.auto_load:
desc: Automatically enter insert mode if an editable element is focused after
loading the page.
+input.insert_mode.auto_enter:
+ default: true
+ type: Bool
+ desc: Enter insert mode if an editable element is clicked.
+
input.insert_mode.auto_leave:
default: true
type: Bool
@@ -978,6 +999,7 @@ input.insert_mode.plugins:
input.links_included_in_focus_chain:
default: true
type: Bool
+ supports_pattern: true
desc: Include hyperlinks in the keyboard focus chain when tabbing.
input.partial_timeout:
@@ -1003,6 +1025,7 @@ input.rocker_gestures:
input.spatial_navigation:
default: false
type: Bool
+ supports_pattern: true
desc: >-
Enable spatial navigation.
@@ -1083,6 +1106,7 @@ scrolling.bar:
scrolling.smooth:
type: Bool
default: false
+ supports_pattern: true
desc: >-
Enable smooth scrolling for web pages.
@@ -1557,6 +1581,7 @@ zoom.text_only:
type: Bool
default: false
backend: QtWebKit
+ supports_pattern: true
desc: Apply the zoom factor on a frame only to the text or to all content.
## colors
@@ -2141,6 +2166,7 @@ bindings.key_mappings:
<Ctrl-Enter>: <Ctrl-Return>
type:
name: Dict
+ none_ok: true
keytype: Key
valtype: Key
desc: >-
@@ -2156,6 +2182,7 @@ bindings.key_mappings:
`bindings.commands`), the mapping is ignored.
bindings.default:
+ no_autoconfig: true
default:
normal:
<Escape>: clear-keychain ;; search ;; fullscreen --leave
@@ -2309,6 +2336,18 @@ bindings.default:
<Ctrl-p>: tab-pin
q: record-macro
"@": run-macro
+ tsh: config-cycle -p -t -u *://{url:host}/* content.javascript.enabled ;; reload
+ tSh: config-cycle -p -u *://{url:host}/* content.javascript.enabled ;; reload
+ tsH: config-cycle -p -t -u *://*.{url:host}/* content.javascript.enabled ;; reload
+ tSH: config-cycle -p -u *://*.{url:host}/* content.javascript.enabled ;; reload
+ tsu: config-cycle -p -t -u {url} content.javascript.enabled ;; reload
+ tSu: config-cycle -p -u {url} content.javascript.enabled ;; reload
+ tph: config-cycle -p -t -u *://{url:host}/* content.plugins ;; reload
+ tPh: config-cycle -p -u *://{url:host}/* content.plugins ;; reload
+ tpH: config-cycle -p -t -u *://*.{url:host}/* content.plugins ;; reload
+ tPH: config-cycle -p -u *://*.{url:host}/* content.plugins ;; reload
+ tpu: config-cycle -p -t -u {url} content.plugins ;; reload
+ tPu: config-cycle -p -u {url} content.plugins ;; reload
insert:
<Ctrl-E>: open-editor
<Shift-Ins>: insert-text {primary}
@@ -2353,8 +2392,6 @@ bindings.default:
<Escape>: leave-mode
prompt:
<Return>: prompt-accept
- y: prompt-accept yes
- n: prompt-accept no
<Ctrl-X>: prompt-open-download
<Shift-Tab>: prompt-item-focus prev
<Up>: prompt-item-focus prev
@@ -2377,6 +2414,13 @@ bindings.default:
<Ctrl-H>: rl-backward-delete-char
<Ctrl-Y>: rl-yank
<Escape>: leave-mode
+ yesno:
+ <Return>: prompt-accept
+ y: prompt-accept yes
+ n: prompt-accept no
+ <Alt-Y>: prompt-yank
+ <Alt-Shift-Y>: prompt-yank --sel
+ <Escape>: leave-mode
caret:
v: toggle-selection
<Space>: toggle-selection
@@ -2412,7 +2456,7 @@ bindings.default:
none_ok: true
keytype: String # section name
fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command',
- 'prompt', 'caret', 'register']
+ 'prompt', 'yesno', 'caret', 'register']
valtype:
name: Dict
none_ok: true
@@ -2436,14 +2480,14 @@ bindings.commands:
none_ok: true
keytype: String # section name
fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command',
- 'prompt', 'caret', 'register']
+ 'prompt', 'yesno', 'caret', 'register']
valtype:
name: Dict
none_ok: true
keytype: Key
valtype:
name: Command
- none_ok: true
+ none_ok: true # needed for :unbind
desc: >-
Keybindings mapping keys to commands in different modes.
@@ -2459,7 +2503,6 @@ bindings.commands:
If you want to map a key to another key, check the `bindings.key_mappings`
setting instead.
- For special keys (can't be part of a keychain), enclose them in `<`...`>`.
For modifiers, you can use either `-` or `+` as delimiters, and these
names:
@@ -2508,10 +2551,8 @@ bindings.commands:
* prompt: Entered when there's a prompt to display, like for download
locations or when invoked from JavaScript.
- +
- You can bind normal keys in this mode, but they will be only active when
- a yes/no-prompt is asked. For other prompt modes, you can only bind
- special keys.
+
+ * yesno: Entered when there's a yes/no prompt displayed.
* caret: Entered when pressing the `v` mode, used to select text using the
keyboard.
diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py
index 0a4986efa..4d1ab5d7f 100644
--- a/qutebrowser/config/configexc.py
+++ b/qutebrowser/config/configexc.py
@@ -31,6 +31,15 @@ class Error(Exception):
pass
+class NoAutoconfigError(Error):
+
+ """Raised when this option can't be set in autoconfig.yml."""
+
+ def __init__(self, name):
+ super().__init__("The {} setting can only be set in config.py!"
+ .format(name))
+
+
class BackendError(Error):
"""Raised when this setting is unavailable with the current backend."""
@@ -40,6 +49,15 @@ class BackendError(Error):
"backend!".format(name, backend.name))
+class NoPatternError(Error):
+
+ """Raised when the given setting does not support URL patterns."""
+
+ def __init__(self, name):
+ super().__init__("The {} setting does not support URL patterns!"
+ .format(name))
+
+
class ValidationError(Error):
"""Raised when a value for a config type was invalid.
@@ -92,6 +110,10 @@ class ConfigErrorDesc:
traceback = attr.ib(None)
def __str__(self):
+ if self.traceback:
+ return '{} - {}: {}'.format(self.text,
+ self.exception.__class__.__name__,
+ self.exception)
return '{}: {}'.format(self.text, self.exception)
def with_text(self, text):
diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py
index 692474075..fdb1583e0 100644
--- a/qutebrowser/config/configfiles.py
+++ b/qutebrowser/config/configfiles.py
@@ -32,8 +32,9 @@ import yaml
from PyQt5.QtCore import pyqtSignal, QObject, QSettings
import qutebrowser
-from qutebrowser.config import configexc, config, configdata
-from qutebrowser.utils import standarddir, utils, qtutils, log
+from qutebrowser.config import configexc, config, configdata, configutils
+from qutebrowser.keyinput import keyutils
+from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch
# The StateConfig instance
@@ -80,16 +81,19 @@ class YamlConfig(QObject):
VERSION: The current version number of the config file.
"""
- VERSION = 1
+ VERSION = 2
changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._filename = os.path.join(standarddir.config(auto=True),
'autoconfig.yml')
- self._values = {}
self._dirty = None
+ self._values = {}
+ for name, opt in configdata.DATA.items():
+ self._values[name] = configutils.Values(opt)
+
def init_save_manager(self, save_manager):
"""Make sure the config gets saved properly.
@@ -98,18 +102,9 @@ class YamlConfig(QObject):
"""
save_manager.add_saveable('yaml-config', self._save, self.changed)
- def __getitem__(self, name):
- return self._values[name]
-
- def __setitem__(self, name, value):
- self._values[name] = value
- self._mark_changed()
-
- def __contains__(self, name):
- return name in self._values
-
def __iter__(self):
- return iter(sorted(self._values.items()))
+ """Iterate over configutils.Values items."""
+ yield from self._values.values()
def _mark_changed(self):
"""Mark the YAML config as changed."""
@@ -121,7 +116,17 @@ class YamlConfig(QObject):
if not self._dirty:
return
- data = {'config_version': self.VERSION, 'global': self._values}
+ settings = {}
+ for name, values in sorted(self._values.items()):
+ if not values:
+ continue
+ settings[name] = {}
+ for scoped in values:
+ key = ('global' if scoped.pattern is None
+ else str(scoped.pattern))
+ settings[name][key] = scoped.value
+
+ data = {'config_version': self.VERSION, 'settings': settings}
with qtutils.savefile_open(self._filename) as f:
f.write(textwrap.dedent("""
# DO NOT edit this file by hand, qutebrowser will overwrite it.
@@ -130,6 +135,29 @@ class YamlConfig(QObject):
""".lstrip('\n')))
utils.yaml_dump(data, f)
+ def _pop_object(self, yaml_data, key, typ):
+ """Get a global object from the given data."""
+ if not isinstance(yaml_data, dict):
+ desc = configexc.ConfigErrorDesc("While loading data",
+ "Toplevel object is not a dict")
+ raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+
+ if key not in yaml_data:
+ desc = configexc.ConfigErrorDesc(
+ "While loading data",
+ "Toplevel object does not contain '{}' key".format(key))
+ raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+
+ data = yaml_data.pop(key)
+
+ if not isinstance(data, typ):
+ desc = configexc.ConfigErrorDesc(
+ "While loading data",
+ "'{}' object is not a {}".format(key, typ.__name__))
+ raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+
+ return data
+
def load(self):
"""Load configuration from the configured YAML file."""
try:
@@ -144,76 +172,132 @@ class YamlConfig(QObject):
desc = configexc.ConfigErrorDesc("While parsing", e)
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
- try:
- global_obj = yaml_data['global']
- except KeyError:
- desc = configexc.ConfigErrorDesc(
- "While loading data",
- "Toplevel object does not contain 'global' key")
- raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
- except TypeError:
- desc = configexc.ConfigErrorDesc("While loading data",
- "Toplevel object is not a dict")
- raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
-
- if not isinstance(global_obj, dict):
+ config_version = self._pop_object(yaml_data, 'config_version', int)
+ if config_version == 1:
+ settings = self._load_legacy_settings_object(yaml_data)
+ self._mark_changed()
+ elif config_version > self.VERSION:
desc = configexc.ConfigErrorDesc(
- "While loading data",
- "'global' object is not a dict")
+ "While reading",
+ "Can't read config from incompatible newer version")
raise configexc.ConfigFileErrors('autoconfig.yml', [desc])
+ else:
+ settings = self._load_settings_object(yaml_data)
+ self._dirty = False
+
+ settings = self._handle_migrations(settings)
+ self._validate(settings)
+ self._build_values(settings)
+
+ def _load_settings_object(self, yaml_data):
+ """Load the settings from the settings: key."""
+ return self._pop_object(yaml_data, 'settings', dict)
+
+ def _load_legacy_settings_object(self, yaml_data):
+ data = self._pop_object(yaml_data, 'global', dict)
+ settings = {}
+ for name, value in data.items():
+ settings[name] = {'global': value}
+ return settings
+
+ def _build_values(self, settings):
+ """Build up self._values from the values in the given dict."""
+ errors = []
+ for name, yaml_values in settings.items():
+ if not isinstance(yaml_values, dict):
+ errors.append(configexc.ConfigErrorDesc(
+ "While parsing {!r}".format(name), "value is not a dict"))
+ continue
- self._values = global_obj
- self._dirty = False
-
- self._handle_migrations()
- self._validate()
+ values = configutils.Values(configdata.DATA[name])
+ if 'global' in yaml_values:
+ values.add(yaml_values.pop('global'))
+
+ for pattern, value in yaml_values.items():
+ if not isinstance(pattern, str):
+ errors.append(configexc.ConfigErrorDesc(
+ "While parsing {!r}".format(name),
+ "pattern is not of type string"))
+ continue
+ try:
+ urlpattern = urlmatch.UrlPattern(pattern)
+ except urlmatch.ParseError as e:
+ errors.append(configexc.ConfigErrorDesc(
+ "While parsing pattern {!r} for {!r}"
+ .format(pattern, name), e))
+ continue
+ values.add(value, urlpattern)
+
+ self._values[name] = values
+
+ if errors:
+ raise configexc.ConfigFileErrors('autoconfig.yml', errors)
- def _handle_migrations(self):
+ def _handle_migrations(self, settings):
"""Migrate older configs to the newest format."""
# Simple renamed/deleted options
- for name in list(self._values):
+ for name in list(settings):
if name in configdata.MIGRATIONS.renamed:
new_name = configdata.MIGRATIONS.renamed[name]
log.config.debug("Renaming {} to {}".format(name, new_name))
- self._values[new_name] = self._values[name]
- del self._values[name]
+ settings[new_name] = settings[name]
+ del settings[name]
self._mark_changed()
elif name in configdata.MIGRATIONS.deleted:
log.config.debug("Removing {}".format(name))
- del self._values[name]
+ del settings[name]
self._mark_changed()
# tabs.persist_mode_on_change got merged into tabs.mode_on_change
old = 'tabs.persist_mode_on_change'
new = 'tabs.mode_on_change'
- if old in self._values:
- if self._values[old]:
- self._values[new] = 'persist'
- else:
- self._values[new] = 'normal'
- del self._values[old]
+ if old in settings:
+ settings[new] = {}
+ for scope, val in settings[old].items():
+ if val:
+ settings[new][scope] = 'persist'
+ else:
+ settings[new][scope] = 'normal'
+
+ del settings[old]
+ self._mark_changed()
+
+ # bindings.default can't be set in autoconfig.yml anymore, so ignore
+ # old values.
+ if 'bindings.default' in settings:
+ del settings['bindings.default']
self._mark_changed()
- def _validate(self):
+ return settings
+
+ def _validate(self, settings):
"""Make sure all settings exist."""
- unknown = set(self._values) - set(configdata.DATA)
+ unknown = []
+ for name in settings:
+ if name not in configdata.DATA:
+ unknown.append(name)
+
if unknown:
errors = [configexc.ConfigErrorDesc("While loading options",
"Unknown option {}".format(e))
for e in sorted(unknown)]
raise configexc.ConfigFileErrors('autoconfig.yml', errors)
- def unset(self, name):
- """Remove the given option name if it's configured."""
- try:
- del self._values[name]
- except KeyError:
- return
+ def set_obj(self, name, value, *, pattern=None):
+ """Set the given setting to the given value."""
+ self._values[name].add(value, pattern)
self._mark_changed()
+ def unset(self, name, *, pattern=None):
+ """Remove the given option name if it's configured."""
+ changed = self._values[name].remove(pattern)
+ if changed:
+ self._mark_changed()
+
def clear(self):
"""Clear all values from the YAML file."""
- self._values = []
+ for values in self._values.values():
+ values.clear()
self._mark_changed()
@@ -242,6 +326,7 @@ class ConfigAPI:
@contextlib.contextmanager
def _handle_error(self, action, name):
+ """Catch config-related exceptions and save them in self.errors."""
try:
yield
except configexc.ConfigFileErrors as e:
@@ -251,30 +336,45 @@ class ConfigAPI:
except configexc.Error as e:
text = "While {} '{}'".format(action, name)
self.errors.append(configexc.ConfigErrorDesc(text, e))
+ except urlmatch.ParseError as e:
+ text = "While {} '{}' and parsing pattern".format(action, name)
+ self.errors.append(configexc.ConfigErrorDesc(text, e))
+ except keyutils.KeyParseError as e:
+ text = "While {} '{}' and parsing key".format(action, name)
+ self.errors.append(configexc.ConfigErrorDesc(text, e))
def finalize(self):
"""Do work which needs to be done after reading config.py."""
self._config.update_mutables()
def load_autoconfig(self):
+ """Load the autoconfig.yml file which is used for :set/:bind/etc."""
with self._handle_error('reading', 'autoconfig.yml'):
read_autoconfig()
- def get(self, name):
+ def get(self, name, pattern=None):
+ """Get a setting value from the config, optionally with a pattern."""
with self._handle_error('getting', name):
- return self._config.get_obj(name)
+ urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
+ return self._config.get_mutable_obj(name, pattern=urlpattern)
- def set(self, name, value):
+ def set(self, name, value, pattern=None):
+ """Set a setting value in the config, optionally with a pattern."""
with self._handle_error('setting', name):
- self._config.set_obj(name, value)
+ urlpattern = urlmatch.UrlPattern(pattern) if pattern else None
+ self._config.set_obj(name, value, pattern=urlpattern)
def bind(self, key, command, mode='normal'):
+ """Bind a key to a command, with an optional key mode."""
with self._handle_error('binding', key):
- self._keyconfig.bind(key, command, mode=mode)
+ seq = keyutils.KeySequence.parse(key)
+ self._keyconfig.bind(seq, command, mode=mode)
def unbind(self, key, mode='normal'):
+ """Unbind a key from a command, with an optional key mode."""
with self._handle_error('unbinding', key):
- self._keyconfig.unbind(key, mode=mode)
+ seq = keyutils.KeySequence.parse(key)
+ self._keyconfig.unbind(seq, mode=mode)
def source(self, filename):
"""Read the given config file from disk."""
@@ -286,6 +386,16 @@ class ConfigAPI:
except configexc.ConfigFileErrors as e:
self.errors += e.errors
+ @contextlib.contextmanager
+ def pattern(self, pattern):
+ """Get a ConfigContainer for the given pattern."""
+ # We need to propagate the exception so we don't need to return
+ # something.
+ urlpattern = urlmatch.UrlPattern(pattern)
+ container = config.ConfigContainer(config=self._config, configapi=self,
+ pattern=urlpattern)
+ yield container
+
class ConfigPyWriter:
@@ -344,7 +454,7 @@ class ConfigPyWriter:
def _gen_options(self):
"""Generate the options part of the config."""
- for opt, value in self._options:
+ for pattern, opt, value in self._options:
if opt.name in ['bindings.commands', 'bindings.default']:
continue
@@ -363,7 +473,11 @@ class ConfigPyWriter:
except KeyError:
yield self._line("# - {}".format(val))
- yield self._line('c.{} = {!r}'.format(opt.name, value))
+ if pattern is None:
+ yield self._line('c.{} = {!r}'.format(opt.name, value))
+ else:
+ yield self._line('config.set({!r}, {!r}, {!r})'.format(
+ opt.name, value, str(pattern)))
yield ''
def _gen_bindings(self):
@@ -419,7 +533,7 @@ def read_config_py(filename, raising=False):
desc = configexc.ConfigErrorDesc("Error while compiling", e)
raise configexc.ConfigFileErrors(basename, [desc])
except SyntaxError as e:
- desc = configexc.ConfigErrorDesc("Syntax Error", e,
+ desc = configexc.ConfigErrorDesc("Unhandled exception", e,
traceback=traceback.format_exc())
raise configexc.ConfigFileErrors(basename, [desc])
diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py
index 26998d510..20e240690 100644
--- a/qutebrowser/config/configtypes.py
+++ b/qutebrowser/config/configtypes.py
@@ -62,6 +62,7 @@ from PyQt5.QtWidgets import QTabWidget, QTabBar
from qutebrowser.commands import cmdutils
from qutebrowser.config import configexc
from qutebrowser.utils import standarddir, utils, qtutils, urlutils
+from qutebrowser.keyinput import keyutils
SYSTEM_PROXY = object() # Return value for Proxy type
@@ -450,7 +451,7 @@ class List(BaseType):
def from_obj(self, value):
if value is None:
return []
- return value
+ return [self.valtype.from_obj(v) for v in value]
def to_py(self, value):
self._basic_py_validation(value, list)
@@ -505,6 +506,16 @@ class ListOrValue(BaseType):
self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs)
self.valtype = valtype
+ def _val_and_type(self, value):
+ """Get the value and type to use for to_str/to_doc/from_str."""
+ if isinstance(value, list):
+ if len(value) == 1:
+ return value[0], self.valtype
+ else:
+ return value, self.listtype
+ else:
+ return value, self.valtype
+
def get_name(self):
return self.listtype.get_name() + ', or ' + self.valtype.get_name()
@@ -532,25 +543,15 @@ class ListOrValue(BaseType):
if value is None:
return ''
- if isinstance(value, list):
- if len(value) == 1:
- return self.valtype.to_str(value[0])
- else:
- return self.listtype.to_str(value)
- else:
- return self.valtype.to_str(value)
+ val, typ = self._val_and_type(value)
+ return typ.to_str(val)
def to_doc(self, value, indent=0):
if value is None:
return 'empty'
- if isinstance(value, list):
- if len(value) == 1:
- return self.valtype.to_doc(value[0], indent)
- else:
- return self.listtype.to_doc(value, indent)
- else:
- return self.valtype.to_doc(value, indent)
+ val, typ = self._val_and_type(value)
+ return typ.to_doc(val)
class FlagList(List):
@@ -1198,7 +1199,9 @@ class Dict(BaseType):
def from_obj(self, value):
if value is None:
return {}
- return value
+
+ return {self.keytype.from_obj(key): self.valtype.from_obj(val)
+ for key, val in value.items()}
def _fill_fixed_keys(self, value):
"""Fill missing fixed keys with a None-value."""
@@ -1647,10 +1650,16 @@ class Key(BaseType):
"""A name of a key."""
+ def from_obj(self, value):
+ """Make sure key sequences are always normalized."""
+ return str(keyutils.KeySequence.parse(value))
+
def to_py(self, value):
self._basic_py_validation(value, str)
if not value:
return None
- if utils.is_special_key(value):
- value = '<{}>'.format(utils.normalize_keystr(value[1:-1]))
- return value
+
+ try:
+ return keyutils.KeySequence.parse(value)
+ except keyutils.KeyParseError as e:
+ raise configexc.ValidationError(value, str(e))
diff --git a/qutebrowser/config/configutils.py b/qutebrowser/config/configutils.py
new file mode 100644
index 000000000..96fc0f02d
--- /dev/null
+++ b/qutebrowser/config/configutils.py
@@ -0,0 +1,186 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+
+"""Utilities and data structures used by various config code."""
+
+
+import attr
+
+from qutebrowser.utils import utils
+from qutebrowser.config import configexc
+
+
+class _UnsetObject:
+
+ """Sentinel object."""
+
+ __slots__ = ()
+
+ def __repr__(self):
+ return '<UNSET>'
+
+
+UNSET = _UnsetObject()
+
+
+@attr.s
+class ScopedValue:
+
+ """A configuration value which is valid for a UrlPattern.
+
+ Attributes:
+ value: The value itself.
+ pattern: The UrlPattern for the value, or None for global values.
+ """
+
+ value = attr.ib()
+ pattern = attr.ib()
+
+
+class Values:
+
+ """A collection of values for a single setting.
+
+ Currently, this is a list and iterates through all possible ScopedValues to
+ find matching ones.
+
+ In the future, it should be possible to optimize this by doing
+ pre-selection based on hosts, by making this a dict mapping the
+ non-wildcard part of the host to a list of matching ScopedValues.
+
+ That way, when searching for a setting for sub.example.com, we only have to
+ check 'sub.example.com', 'example.com', '.com' and '' instead of checking
+ all ScopedValues for the given setting.
+
+ Attributes:
+ opt: The Option being customized.
+ """
+
+ def __init__(self, opt, values=None):
+ self.opt = opt
+ self._values = values or []
+
+ def __repr__(self):
+ return utils.get_repr(self, opt=self.opt, values=self._values,
+ constructor=True)
+
+ def __str__(self):
+ """Get the values as human-readable string."""
+ if not self:
+ return '{}: <unchanged>'.format(self.opt.name)
+
+ lines = []
+ for scoped in self._values:
+ str_value = self.opt.typ.to_str(scoped.value)
+ if scoped.pattern is None:
+ lines.append('{} = {}'.format(self.opt.name, str_value))
+ else:
+ lines.append('{}: {} = {}'.format(
+ scoped.pattern, self.opt.name, str_value))
+ return '\n'.join(lines)
+
+ def __iter__(self):
+ """Yield ScopedValue elements.
+
+ This yields in "normal" order, i.e. global and then first-set settings
+ first.
+ """
+ yield from self._values
+
+ def __bool__(self):
+ """Check whether this value is customized."""
+ return bool(self._values)
+
+ def _check_pattern_support(self, arg):
+ """Make sure patterns are supported if one was given."""
+ if arg is not None and not self.opt.supports_pattern:
+ raise configexc.NoPatternError(self.opt.name)
+
+ def add(self, value, pattern=None):
+ """Add a value with the given pattern to the list of values."""
+ self._check_pattern_support(pattern)
+ self.remove(pattern)
+ scoped = ScopedValue(value, pattern)
+ self._values.append(scoped)
+
+ def remove(self, pattern=None):
+ """Remove the value with the given pattern.
+
+ If a matching pattern was removed, True is returned.
+ If no matching pattern was found, False is returned.
+ """
+ self._check_pattern_support(pattern)
+ old_len = len(self._values)
+ self._values = [v for v in self._values if v.pattern != pattern]
+ return old_len != len(self._values)
+
+ def clear(self):
+ """Clear all customization for this value."""
+ self._values = []
+
+ def _get_fallback(self, fallback):
+ """Get the fallback global/default value."""
+ for scoped in self._values:
+ if scoped.pattern is None:
+ return scoped.value
+
+ if fallback:
+ return self.opt.default
+ else:
+ return UNSET
+
+ def get_for_url(self, url=None, *, fallback=True):
+ """Get a config value, falling back when needed.
+
+ This first tries to find a value matching the URL (if given).
+ If there's no match:
+ With fallback=True, the global/default setting is returned.
+ With fallback=False, UNSET is returned.
+ """
+ self._check_pattern_support(url)
+ if url is not None:
+ for scoped in reversed(self._values):
+ if scoped.pattern is not None and scoped.pattern.matches(url):
+ return scoped.value
+
+ if not fallback:
+ return UNSET
+
+ return self._get_fallback(fallback)
+
+ def get_for_pattern(self, pattern, *, fallback=True):
+ """Get a value only if it's been overridden for the given pattern.
+
+ This is useful when showing values to the user.
+
+ If there's no match:
+ With fallback=True, the global/default setting is returned.
+ With fallback=False, UNSET is returned.
+ """
+ self._check_pattern_support(pattern)
+ if pattern is not None:
+ for scoped in reversed(self._values):
+ if scoped.pattern == pattern:
+ return scoped.value
+
+ if not fallback:
+ return UNSET
+
+ return self._get_fallback(fallback)
diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py
index fa8abb76f..cfb53e658 100644
--- a/qutebrowser/config/websettings.py
+++ b/qutebrowser/config/websettings.py
@@ -17,195 +17,151 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-# We get various "abstract but not overridden" warnings
-# pylint: disable=abstract-method
-
"""Bridge from QWeb(Engine)Settings to our own settings."""
from PyQt5.QtGui import QFont
-from qutebrowser.config import config
-from qutebrowser.utils import log, utils, debug, usertypes
+from qutebrowser.config import config, configutils
+from qutebrowser.utils import log, usertypes, urlmatch, qtutils
from qutebrowser.misc import objects
UNSET = object()
-class Base:
+class AbstractSettings:
- """Base class for QWeb(Engine)Settings wrappers."""
+ """Abstract base class for settings set via QWeb(Engine)Settings."""
- def __init__(self, default=UNSET):
- self._default = default
+ _ATTRIBUTES = None
+ _FONT_SIZES = None
+ _FONT_FAMILIES = None
+ _FONT_TO_QFONT = None
- def _get_global_settings(self):
- """Get a list of global QWeb(Engine)Settings to use."""
- raise NotImplementedError
+ def __init__(self, settings):
+ self._settings = settings
- def _get_settings(self, settings):
- """Get a list of QWeb(Engine)Settings objects to use.
+ def set_attribute(self, name, value):
+ """Set the given QWebSettings/QWebEngineSettings attribute.
- Args:
- settings: The QWeb(Engine)Settings instance to use, or None to use
- the global instance.
+ If the value is configutils.UNSET, the value is reset instead.
Return:
- A list of QWeb(Engine)Settings objects. The first one should be
- used for reading.
- """
- if settings is None:
- return self._get_global_settings()
- else:
- return [settings]
-
- def set(self, value, settings=None):
- """Set the value of this setting.
-
- Args:
- value: The value to set, or None to restore the default.
- settings: The QWeb(Engine)Settings instance to use, or None to use
- the global instance.
+ True if there was a change, False otherwise.
"""
- if value is None:
- self.set_default(settings=settings)
- else:
- self._set(value, settings=settings)
+ old_value = self.test_attribute(name)
- def set_default(self, settings=None):
- """Set the default value for this setting.
-
- Not implemented for most settings.
- """
- if self._default is UNSET:
- raise ValueError("No default set for {!r}".format(self))
- else:
- self._set(self._default, settings=settings)
+ for attribute in self._ATTRIBUTES[name]:
+ if value is configutils.UNSET:
+ self._settings.resetAttribute(attribute)
+ new_value = self.test_attribute(name)
+ else:
+ self._settings.setAttribute(attribute, value)
+ new_value = value
- def _set(self, value, settings):
- """Inner function to set the value of this setting.
+ return old_value != new_value
- Must be overridden by subclasses.
+ def test_attribute(self, name):
+ """Get the value for the given attribute.
- Args:
- value: The value to set.
- settings: The QWeb(Engine)Settings instance to use, or None to use
- the global instance.
+ If the setting resolves to a list of attributes, only the first
+ attribute is tested.
"""
- raise NotImplementedError
-
-
-class Attribute(Base):
-
- """A setting set via QWeb(Engine)Settings::setAttribute.
-
- Attributes:
- self._attributes: A list of QWeb(Engine)Settings::WebAttribute members.
- """
-
- ENUM_BASE = None
-
- def __init__(self, *attributes, default=UNSET):
- super().__init__(default=default)
- self._attributes = list(attributes)
-
- def __repr__(self):
- attributes = [debug.qenum_key(self.ENUM_BASE, attr)
- for attr in self._attributes]
- return utils.get_repr(self, attributes=attributes, constructor=True)
-
- def _set(self, value, settings=None):
- for obj in self._get_settings(settings):
- for attribute in self._attributes:
- obj.setAttribute(attribute, value)
-
-
-class Setter(Base):
-
- """A setting set via a QWeb(Engine)Settings setter method.
+ return self._settings.testAttribute(self._ATTRIBUTES[name][0])
- This will pass the QWeb(Engine)Settings instance ("self") as first argument
- to the methods, so self._setter is the *unbound* method.
+ def set_font_size(self, name, value):
+ """Set the given QWebSettings/QWebEngineSettings font size.
- Attributes:
- _setter: The unbound QWeb(Engine)Settings method to set this value.
- _args: An iterable of the arguments to pass to the setter (before the
- value).
- _unpack: Whether to unpack args (True) or pass them directly (False).
- """
-
- def __init__(self, setter, args=(), unpack=False, default=UNSET):
- super().__init__(default=default)
- self._setter = setter
- self._args = args
- self._unpack = unpack
-
- def __repr__(self):
- return utils.get_repr(self, setter=self._setter, args=self._args,
- unpack=self._unpack, constructor=True)
-
- def _set(self, value, settings=None):
- for obj in self._get_settings(settings):
- args = [obj]
- args.extend(self._args)
- if self._unpack:
- args.extend(value)
- else:
- args.append(value)
- self._setter(*args)
+ Return:
+ True if there was a change, False otherwise.
+ """
+ assert value is not configutils.UNSET
+ family = self._FONT_SIZES[name]
+ old_value = self._settings.fontSize(family)
+ self._settings.setFontSize(family, value)
+ return old_value != value
+ def set_font_family(self, name, value):
+ """Set the given QWebSettings/QWebEngineSettings font family.
-class StaticSetter(Setter):
+ With None (the default), QFont is used to get the default font for the
+ family.
- """A setting set via a static QWeb(Engine)Settings method.
+ Return:
+ True if there was a change, False otherwise.
+ """
+ assert value is not configutils.UNSET
+ family = self._FONT_FAMILIES[name]
+ if value is None:
+ font = QFont()
+ font.setStyleHint(self._FONT_TO_QFONT[family])
+ value = font.defaultFamily()
- self._setter is the *bound* method.
- """
+ old_value = self._settings.fontFamily(family)
+ self._settings.setFontFamily(family, value)
- def _set(self, value, settings=None):
- if settings is not None:
- raise ValueError("'settings' may not be set with StaticSetters!")
- args = list(self._args)
- if self._unpack:
- args.extend(value)
- else:
- args.append(value)
- self._setter(*args)
+ return value != old_value
+ def set_default_text_encoding(self, encoding):
+ """Set the default text encoding to use.
-class FontFamilySetter(Setter):
+ Return:
+ True if there was a change, False otherwise.
+ """
+ assert encoding is not configutils.UNSET
+ old_value = self._settings.defaultTextEncoding()
+ self._settings.setDefaultTextEncoding(encoding)
+ return old_value != encoding
- """A setter for a font family.
+ def _update_setting(self, setting, value):
+ """Update the given setting/value.
- Gets the default value from QFont.
- """
+ Unknown settings are ignored.
- def __init__(self, setter, font, qfont):
- super().__init__(setter=setter, args=[font])
- self._qfont = qfont
+ Return:
+ True if there was a change, False otherwise.
+ """
+ if setting in self._ATTRIBUTES:
+ return self.set_attribute(setting, value)
+ elif setting in self._FONT_SIZES:
+ return self.set_font_size(setting, value)
+ elif setting in self._FONT_FAMILIES:
+ return self.set_font_family(setting, value)
+ elif setting == 'content.default_encoding':
+ return self.set_default_text_encoding(value)
+ return False
+
+ def update_setting(self, setting):
+ """Update the given setting."""
+ value = config.instance.get(setting)
+ self._update_setting(setting, value)
+
+ def update_for_url(self, url):
+ """Update settings customized for the given tab.
- def set_default(self, settings=None):
- font = QFont()
- font.setStyleHint(self._qfont)
- value = font.defaultFamily()
- self._set(value, settings=settings)
+ Return:
+ A set of settings which actually changed.
+ """
+ qtutils.ensure_valid(url)
+ changed_settings = set()
+ for values in config.instance:
+ if not values.opt.supports_pattern:
+ continue
+ value = values.get_for_url(url, fallback=False)
-def init_mappings(mappings):
- """Initialize all settings based on a settings mapping."""
- for option, mapping in mappings.items():
- value = config.instance.get(option)
- log.config.vdebug("Setting {} to {!r}".format(option, value))
- mapping.set(value)
+ changed = self._update_setting(values.opt.name, value)
+ if changed:
+ log.config.debug("Changed for {}: {} = {}".format(
+ url.toDisplayString(), values.opt.name, value))
+ changed_settings.add(values.opt.name)
+ return changed_settings
-def update_mappings(mappings, option):
- """Update global settings when QWeb(Engine)Settings changed."""
- try:
- mapping = mappings[option]
- except KeyError:
- return
- value = config.instance.get(option)
- mapping.set(value)
+ def init_settings(self):
+ """Set all supported settings correctly."""
+ for setting in (list(self._ATTRIBUTES) + list(self._FONT_SIZES) +
+ list(self._FONT_FAMILIES)):
+ self.update_setting(setting)
def init(args):
@@ -217,6 +173,11 @@ def init(args):
from qutebrowser.browser.webkit import webkitsettings
webkitsettings.init(args)
+ # Make sure special URLs always get JS support
+ for pattern in ['file://*', 'chrome://*/*', 'qute://*/*']:
+ config.instance.set_obj('content.javascript.enabled', True,
+ pattern=urlmatch.UrlPattern(pattern))
+
def shutdown():
"""Shut down QWeb(Engine)Settings."""
diff --git a/qutebrowser/html/settings.html b/qutebrowser/html/settings.html
index b370c0d91..62b424a59 100644
--- a/qutebrowser/html/settings.html
+++ b/qutebrowser/html/settings.html
@@ -33,7 +33,7 @@ input { width: 98%; }
<th>Setting</th>
<th>Value</th>
</tr>
- {% for option in configdata.DATA.values() %}
+ {% for option in configdata.DATA.values() if not option.no_autoconfig %}
<tr>
<!-- FIXME: convert to string properly -->
<td class="setting">{{ option.name }} (Current: {{ confget(option.name) | string |truncate(100) }})
diff --git a/qutebrowser/html/tabs.html b/qutebrowser/html/tabs.html
new file mode 100644
index 000000000..fff8bdca3
--- /dev/null
+++ b/qutebrowser/html/tabs.html
@@ -0,0 +1,58 @@
+{% extends "styled.html" %}
+
+{% block style %}
+{{super()}}
+h1 {
+ margin-bottom: 10px;
+}
+
+.url a {
+ color: #444;
+}
+
+th {
+ text-align: left;
+}
+
+.qmarks .name {
+ padding-left: 5px;
+}
+
+.empty-msg {
+ background-color: #f8f8f8;
+ color: #444;
+ display: inline-block;
+ text-align: center;
+ width: 100%;
+}
+
+details {
+ margin-top: 20px;
+}
+{% endblock %}
+
+{% block content %}
+
+<h1>Tab list</h1>
+{% for win_id, tabs in tab_list_by_window.items() %}
+<h2>Window {{ win_id }}</h2>
+<table class="tabs_win{{win_id}}">
+ <tbody>
+ {% for name, url in tabs %}
+ <tr>
+ <td class="name"><a href="{{url}}">{{name}}</a></td>
+ <td class="url"><a href="{{url}}">{{url}}</a></td>
+ </tr>
+ {% endfor %}
+ </tbody>
+</table>
+{% endfor %}
+<details>
+ <summary>Raw list</summary>
+<code>
+{% for win_id, tabs in tab_list_by_window.items() %}{% for name, url in tabs %}
+{{url}}</br>{% endfor %}
+{% endfor %}
+</code>
+</details>
+{% endblock %}
diff --git a/qutebrowser/html/undef_error.html b/qutebrowser/html/undef_error.html
deleted file mode 100644
index 55a47ca95..000000000
--- a/qutebrowser/html/undef_error.html
+++ /dev/null
@@ -1,22 +0,0 @@
-<!DOCTYPE html>
-<!--
-vim: ft=html fileencoding=utf-8 sts=4 sw=4 et:
--->
-
-<html>
- <head>
- <meta charset="utf-8">
- <title>Error while rendering HTML</title>
- </head>
- <body>
- <h1>Error while rendering internal qutebrowser page</h1>
- <p>There was an error while rendering {pagename}.</p>
-
- <p>This most likely happened because you updated qutebrowser but didn't restart yet.</p>
-
- <p>If you believe this isn't the case and this is a bug, please do :report.<p>
-
- <h2>Traceback</h2>
- <pre>{traceback}</pre>
- </body>
-</html>
diff --git a/qutebrowser/javascript/greasemonkey_wrapper.js b/qutebrowser/javascript/greasemonkey_wrapper.js
index 2d36220dc..71266755a 100644
--- a/qutebrowser/javascript/greasemonkey_wrapper.js
+++ b/qutebrowser/javascript/greasemonkey_wrapper.js
@@ -110,6 +110,44 @@
}
}
+ // Stub these two so that the gm4 polyfill script doesn't try to
+ // create broken versions as attributes of window.
+ function GM_getResourceText(caption, commandFunc, accessKey) {
+ console.error(`${GM_info.script.name} called unimplemented GM_getResourceText`);
+ }
+
+ function GM_registerMenuCommand(caption, commandFunc, accessKey) {
+ console.error(`${GM_info.script.name} called unimplemented GM_registerMenuCommand`);
+ }
+
+ // Mock the greasemonkey 4.0 async API.
+ const GM = {};
+ GM.info = GM_info;
+ const entries = {
+ 'log': GM_log,
+ 'addStyle': GM_addStyle,
+ 'deleteValue': GM_deleteValue,
+ 'getValue': GM_getValue,
+ 'listValues': GM_listValues,
+ 'openInTab': GM_openInTab,
+ 'setValue': GM_setValue,
+ 'xmlHttpRequest': GM_xmlhttpRequest,
+ }
+ for (newKey in entries) {
+ let old = entries[newKey];
+ if (old && (typeof GM[newKey] == 'undefined')) {
+ GM[newKey] = function(...args) {
+ return new Promise((resolve, reject) => {
+ try {
+ resolve(old(...args));
+ } catch (e) {
+ reject(e);
+ }
+ });
+ };
+ }
+ };
+
const unsafeWindow = window;
// ====== The actual user script source ====== //
diff --git a/qutebrowser/javascript/webelem.js b/qutebrowser/javascript/webelem.js
index d635de412..f7ab0f636 100644
--- a/qutebrowser/javascript/webelem.js
+++ b/qutebrowser/javascript/webelem.js
@@ -74,9 +74,8 @@ window._qutebrowser.webelem = (function() {
try {
return elem.selectionStart;
} catch (err) {
- if (err instanceof (frame
- ? frame.DOMException
- : DOMException) &&
+ if ((err instanceof DOMException ||
+ (frame && err instanceof frame.DOMException)) &&
err.name === "InvalidStateError") {
// nothing to do, caret_position is already null
} else {
@@ -331,13 +330,13 @@ window._qutebrowser.webelem = (function() {
// Function for returning a selection to python (so we can click it)
funcs.find_selected_link = () => {
- const elem = window.getSelection().anchorNode;
+ const elem = window.getSelection().baseNode;
if (elem) {
return serialize_elem(elem.parentNode);
}
const serialized_frame_elem = run_frames((frame) => {
- const node = frame.window.getSelection().anchorNode;
+ const node = frame.window.getSelection().baseNode;
if (node) {
return serialize_elem(node.parentNode, frame);
}
diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py
index 89120f922..f0f2c6f28 100644
--- a/qutebrowser/keyinput/basekeyparser.py
+++ b/qutebrowser/keyinput/basekeyparser.py
@@ -19,14 +19,12 @@
"""Base class for vim-like key sequence parser."""
-import enum
-import re
-import unicodedata
-
from PyQt5.QtCore import pyqtSignal, QObject
+from PyQt5.QtGui import QKeySequence
from qutebrowser.config import config
from qutebrowser.utils import usertypes, log, utils
+from qutebrowser.keyinput import keyutils
class BaseKeyParser(QObject):
@@ -43,24 +41,16 @@ class BaseKeyParser(QObject):
definitive: Keychain matches exactly.
none: No more matches possible.
- Types: type of a key binding.
- chain: execute() was called via a chain-like key binding
- special: execute() was called via a special key binding
-
do_log: Whether to log keypresses or not.
passthrough: Whether unbound keys should be passed through with this
handler.
Attributes:
bindings: Bound key bindings
- special_bindings: Bound special bindings (<Foo>).
_win_id: The window ID this keyparser is associated with.
- _warn_on_keychains: Whether a warning should be logged when binding
- keychains in a section which does not support them.
- _keystring: The currently entered key sequence
+ _sequence: The currently entered key sequence
_modename: The name of the input mode associated with this keyparser.
_supports_count: Whether count is supported
- _supports_chains: Whether keychains are supported
Signals:
keystring_updated: Emitted when the keystring is updated.
@@ -76,27 +66,18 @@ class BaseKeyParser(QObject):
do_log = True
passthrough = False
- Match = enum.Enum('Match', ['partial', 'definitive', 'other', 'none'])
- Type = enum.Enum('Type', ['chain', 'special'])
-
- def __init__(self, win_id, parent=None, supports_count=None,
- supports_chains=False):
+ def __init__(self, win_id, parent=None, supports_count=True):
super().__init__(parent)
self._win_id = win_id
self._modename = None
- self._keystring = ''
- if supports_count is None:
- supports_count = supports_chains
+ self._sequence = keyutils.KeySequence()
+ self._count = ''
self._supports_count = supports_count
- self._supports_chains = supports_chains
- self._warn_on_keychains = True
self.bindings = {}
- self.special_bindings = {}
config.instance.changed.connect(self._on_config_changed)
def __repr__(self):
- return utils.get_repr(self, supports_count=self._supports_count,
- supports_chains=self._supports_chains)
+ return utils.get_repr(self, supports_count=self._supports_count)
def _debug_log(self, message):
"""Log a message to the debug log if logging is active.
@@ -107,62 +88,66 @@ class BaseKeyParser(QObject):
if self.do_log:
log.keyboard.debug(message)
- def _handle_special_key(self, e):
- """Handle a new keypress with special keys (<Foo>).
-
- Return True if the keypress has been handled, and False if not.
-
- Args:
- e: the KeyPressEvent from Qt.
-
- Return:
- True if event has been handled, False otherwise.
- """
- binding = utils.keyevent_to_string(e)
- if binding is None:
- self._debug_log("Ignoring only-modifier keyeevent.")
- return False
-
- if binding not in self.special_bindings:
- key_mappings = config.val.bindings.key_mappings
- try:
- binding = key_mappings['<{}>'.format(binding)][1:-1]
- except KeyError:
- pass
-
- try:
- cmdstr = self.special_bindings[binding]
- except KeyError:
- self._debug_log("No special binding found for {}.".format(binding))
- return False
- count, _command = self._split_count(self._keystring)
- self.execute(cmdstr, self.Type.special, count)
- self.clear_keystring()
- return True
-
- def _split_count(self, keystring):
- """Get count and command from the current keystring.
+ def _match_key(self, sequence):
+ """Try to match a given keystring with any bound keychain.
Args:
- keystring: The key string to split.
+ sequence: The command string to find.
Return:
- A (count, command) tuple.
+ A tuple (matchtype, binding).
+ matchtype: Match.definitive, Match.partial or Match.none.
+ binding: - None with Match.partial/Match.none.
+ - The found binding with Match.definitive.
"""
- if self._supports_count:
- (countstr, cmd_input) = re.fullmatch(r'(\d*)(.*)',
- keystring).groups()
- count = int(countstr) if countstr else None
- if count == 0 and not cmd_input:
- cmd_input = keystring
- count = None
- else:
- cmd_input = keystring
- count = None
- return count, cmd_input
-
- def _handle_single_key(self, e):
- """Handle a new keypress with a single key (no modifiers).
+ assert sequence
+ assert not isinstance(sequence, str)
+ result = QKeySequence.NoMatch
+
+ for seq, cmd in self.bindings.items():
+ assert not isinstance(seq, str), seq
+ match = sequence.matches(seq)
+ if match == QKeySequence.ExactMatch:
+ return match, cmd
+ elif match == QKeySequence.PartialMatch:
+ result = QKeySequence.PartialMatch
+
+ return result, None
+
+ def _match_without_modifiers(self, sequence):
+ """Try to match a key with optional modifiers stripped."""
+ self._debug_log("Trying match without modifiers")
+ sequence = sequence.strip_modifiers()
+ match, binding = self._match_key(sequence)
+ return match, binding, sequence
+
+ def _match_key_mapping(self, sequence):
+ """Try to match a key in bindings.key_mappings."""
+ self._debug_log("Trying match with key_mappings")
+ mapped = sequence.with_mappings(config.val.bindings.key_mappings)
+ if sequence != mapped:
+ self._debug_log("Mapped {} -> {}".format(
+ sequence, mapped))
+ match, binding = self._match_key(mapped)
+ sequence = mapped
+ return match, binding, sequence
+ return QKeySequence.NoMatch, None, sequence
+
+ def _match_count(self, sequence, dry_run):
+ """Try to match a key as count."""
+ txt = str(sequence[-1]) # To account for sequences changed above.
+ if (txt.isdigit() and self._supports_count and
+ not (not self._count and txt == '0')):
+ self._debug_log("Trying match as count")
+ assert len(txt) == 1, txt
+ if not dry_run:
+ self._count += txt
+ self.keystring_updated.emit(self._count + str(self._sequence))
+ return True
+ return False
+
+ def handle(self, e, *, dry_run=False):
+ """Handle a new keypress.
Separate the keypress into count/command, then check if it matches
any possible command, and either run the command, ignore it, or
@@ -170,109 +155,62 @@ class BaseKeyParser(QObject):
Args:
e: the KeyPressEvent from Qt.
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Return:
- A self.Match member.
+ A QKeySequence match.
"""
- txt = e.text()
key = e.key()
- self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt))
+ txt = str(keyutils.KeyInfo.from_event(e))
+ self._debug_log("Got key: 0x{:x} / modifiers: 0x{:x} / text: '{}' / "
+ "dry_run {}".format(key, int(e.modifiers()), txt,
+ dry_run))
- if len(txt) == 1:
- category = unicodedata.category(txt)
- is_control_char = (category == 'Cc')
- else:
- is_control_char = False
-
- if (not txt) or is_control_char:
- self._debug_log("Ignoring, no text char")
- return self.Match.none
-
- count, cmd_input = self._split_count(self._keystring + txt)
- match, binding = self._match_key(cmd_input)
- if match == self.Match.none:
- mappings = config.val.bindings.key_mappings
- mapped = mappings.get(txt, None)
- if mapped is not None:
- txt = mapped
- count, cmd_input = self._split_count(self._keystring + txt)
- match, binding = self._match_key(cmd_input)
-
- self._keystring += txt
- if match == self.Match.definitive:
+ if keyutils.is_modifier_key(key):
+ self._debug_log("Ignoring, only modifier")
+ return QKeySequence.NoMatch
+
+ try:
+ sequence = self._sequence.append_event(e)
+ except keyutils.KeyParseError as ex:
+ self._debug_log("{} Aborting keychain.".format(ex))
+ self.clear_keystring()
+ return QKeySequence.NoMatch
+
+ match, binding = self._match_key(sequence)
+ if match == QKeySequence.NoMatch:
+ match, binding, sequence = self._match_without_modifiers(sequence)
+ if match == QKeySequence.NoMatch:
+ match, binding, sequence = self._match_key_mapping(sequence)
+ if match == QKeySequence.NoMatch:
+ was_count = self._match_count(sequence, dry_run)
+ if was_count:
+ return QKeySequence.ExactMatch
+
+ if dry_run:
+ return match
+
+ self._sequence = sequence
+
+ if match == QKeySequence.ExactMatch:
self._debug_log("Definitive match for '{}'.".format(
- self._keystring))
+ sequence))
+ count = int(self._count) if self._count else None
self.clear_keystring()
- self.execute(binding, self.Type.chain, count)
- elif match == self.Match.partial:
+ self.execute(binding, count)
+ elif match == QKeySequence.PartialMatch:
self._debug_log("No match for '{}' (added {})".format(
- self._keystring, txt))
- elif match == self.Match.none:
+ sequence, txt))
+ self.keystring_updated.emit(self._count + str(sequence))
+ elif match == QKeySequence.NoMatch:
self._debug_log("Giving up with '{}', no matches".format(
- self._keystring))
+ sequence))
self.clear_keystring()
- elif match == self.Match.other:
- pass
else:
raise utils.Unreachable("Invalid match value {!r}".format(match))
- return match
-
- def _match_key(self, cmd_input):
- """Try to match a given keystring with any bound keychain.
-
- Args:
- cmd_input: The command string to find.
-
- Return:
- A tuple (matchtype, binding).
- matchtype: Match.definitive, Match.partial or Match.none.
- binding: - None with Match.partial/Match.none.
- - The found binding with Match.definitive.
- """
- if not cmd_input:
- # Only a count, no command yet, but we handled it
- return (self.Match.other, None)
- # A (cmd_input, binding) tuple (k, v of bindings) or None.
- definitive_match = None
- partial_match = False
- # Check definitive match
- try:
- definitive_match = (cmd_input, self.bindings[cmd_input])
- except KeyError:
- pass
- # Check partial match
- for binding in self.bindings:
- if definitive_match is not None and binding == definitive_match[0]:
- # We already matched that one
- continue
- elif binding.startswith(cmd_input):
- partial_match = True
- break
- if definitive_match is not None:
- return (self.Match.definitive, definitive_match[1])
- elif partial_match:
- return (self.Match.partial, None)
- else:
- return (self.Match.none, None)
-
- def handle(self, e):
- """Handle a new keypress and call the respective handlers.
-
- Args:
- e: the KeyPressEvent from Qt
-
- Return:
- True if the event was handled, False otherwise.
- """
- handled = self._handle_special_key(e)
- if handled or not self._supports_chains:
- return handled
- match = self._handle_single_key(e)
- # don't emit twice if the keystring was cleared in self.clear_keystring
- if self._keystring:
- self.keystring_updated.emit(self._keystring)
- return match != self.Match.none
+ return match
@config.change_filter('bindings')
def _on_config_changed(self):
@@ -295,37 +233,26 @@ class BaseKeyParser(QObject):
else:
self._modename = modename
self.bindings = {}
- self.special_bindings = {}
for key, cmd in config.key_instance.get_bindings_for(modename).items():
+ assert not isinstance(key, str), key
assert cmd
- self._parse_key_command(modename, key, cmd)
-
- def _parse_key_command(self, modename, key, cmd):
- """Parse the keys and their command and store them in the object."""
- if utils.is_special_key(key):
- self.special_bindings[key[1:-1]] = cmd
- elif self._supports_chains:
self.bindings[key] = cmd
- elif self._warn_on_keychains:
- log.keyboard.warning("Ignoring keychain '{}' in mode '{}' because "
- "keychains are not supported there."
- .format(key, modename))
- def execute(self, cmdstr, keytype, count=None):
+ def execute(self, cmdstr, count=None):
"""Handle a completed keychain.
Args:
cmdstr: The command to execute as a string.
- keytype: Type.chain or Type.special
count: The count if given.
"""
raise NotImplementedError
def clear_keystring(self):
"""Clear the currently entered key sequence."""
- if self._keystring:
- self._debug_log("discarding keystring '{}'.".format(
- self._keystring))
- self._keystring = ''
- self.keystring_updated.emit(self._keystring)
+ if self._sequence:
+ self._debug_log("Clearing keystring (was: {}).".format(
+ self._sequence))
+ self._sequence = keyutils.KeySequence()
+ self._count = ''
+ self.keystring_updated.emit('')
diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py
deleted file mode 100644
index 8ae27412e..000000000
--- a/qutebrowser/keyinput/keyparser.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
-
-"""Advanced keyparsers."""
-
-import traceback
-
-from qutebrowser.keyinput.basekeyparser import BaseKeyParser
-from qutebrowser.utils import message, utils
-from qutebrowser.commands import runners, cmdexc
-
-
-class CommandKeyParser(BaseKeyParser):
-
- """KeyChainParser for command bindings.
-
- Attributes:
- _commandrunner: CommandRunner instance.
- """
-
- def __init__(self, win_id, parent=None, supports_count=None,
- supports_chains=False):
- super().__init__(win_id, parent, supports_count, supports_chains)
- self._commandrunner = runners.CommandRunner(win_id)
-
- def execute(self, cmdstr, _keytype, count=None):
- try:
- self._commandrunner.run(cmdstr, count)
- except cmdexc.Error as e:
- message.error(str(e), stack=traceback.format_exc())
-
-
-class PassthroughKeyParser(CommandKeyParser):
-
- """KeyChainParser which passes through normal keys.
-
- Used for insert/passthrough modes.
-
- Attributes:
- _mode: The mode this keyparser is for.
- """
-
- do_log = False
- passthrough = True
-
- def __init__(self, win_id, mode, parent=None, warn=True):
- """Constructor.
-
- Args:
- mode: The mode this keyparser is for.
- parent: Qt parent.
- warn: Whether to warn if an ignored key was bound.
- """
- super().__init__(win_id, parent, supports_chains=False)
- self._warn_on_keychains = warn
- self._read_config(mode)
- self._mode = mode
-
- def __repr__(self):
- return utils.get_repr(self, mode=self._mode,
- warn=self._warn_on_keychains)
diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py
new file mode 100644
index 000000000..1f34fcae0
--- /dev/null
+++ b/qutebrowser/keyinput/keyutils.py
@@ -0,0 +1,558 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""Our own QKeySequence-like class and related utilities."""
+
+import itertools
+
+import attr
+from PyQt5.QtCore import Qt, QEvent
+from PyQt5.QtGui import QKeySequence, QKeyEvent
+
+from qutebrowser.utils import utils
+
+
+# Map Qt::Key values to their Qt::KeyboardModifier value.
+_MODIFIER_MAP = {
+ Qt.Key_Shift: Qt.ShiftModifier,
+ Qt.Key_Control: Qt.ControlModifier,
+ Qt.Key_Alt: Qt.AltModifier,
+ Qt.Key_Meta: Qt.MetaModifier,
+ Qt.Key_Mode_switch: Qt.GroupSwitchModifier,
+}
+
+
+def _assert_plain_key(key):
+ """Make sure this is a key without KeyboardModifiers mixed in."""
+ assert not key & Qt.KeyboardModifierMask, hex(key)
+
+
+def _assert_plain_modifier(key):
+ """Make sure this is a modifier without a key mixed in."""
+ assert not key & ~Qt.KeyboardModifierMask, hex(key)
+
+
+def _is_printable(key):
+ _assert_plain_key(key)
+ return key <= 0xff and key not in [Qt.Key_Space, 0x0]
+
+
+def is_special(key, modifiers):
+ """Check whether this key requires special key syntax."""
+ _assert_plain_key(key)
+ _assert_plain_modifier(modifiers)
+ return not (_is_printable(key) and
+ modifiers in [Qt.ShiftModifier, Qt.NoModifier])
+
+
+def is_modifier_key(key):
+ """Test whether the given key is a modifier.
+
+ This only considers keys which are part of Qt::KeyboardModifiers, i.e.
+ which would interrupt a key chain like "yY" when handled.
+ """
+ _assert_plain_key(key)
+ return key in _MODIFIER_MAP
+
+
+def _check_valid_utf8(s, data):
+ """Make sure the given string is valid UTF-8.
+
+ Makes sure there are no chars where Qt did fall back to weird UTF-16
+ surrogates.
+ """
+ try:
+ s.encode('utf-8')
+ except UnicodeEncodeError as e: # pragma: no cover
+ raise ValueError("Invalid encoding in 0x{:x} -> {}: {}"
+ .format(data, s, e))
+
+
+def _key_to_string(key):
+ """Convert a Qt::Key member to a meaningful name.
+
+ Args:
+ key: A Qt::Key member.
+
+ Return:
+ A name of the key as a string.
+ """
+ _assert_plain_key(key)
+ special_names_str = {
+ # Some keys handled in a weird way by QKeySequence::toString.
+ # See https://bugreports.qt.io/browse/QTBUG-40030
+ # Most are unlikely to be ever needed, but you never know ;)
+ # For dead/combining keys, we return the corresponding non-combining
+ # key, as that's easier to add to the config.
+
+ 'Super_L': 'Super L',
+ 'Super_R': 'Super R',
+ 'Hyper_L': 'Hyper L',
+ 'Hyper_R': 'Hyper R',
+ 'Direction_L': 'Direction L',
+ 'Direction_R': 'Direction R',
+
+ 'Shift': 'Shift',
+ 'Control': 'Control',
+ 'Meta': 'Meta',
+ 'Alt': 'Alt',
+
+ 'AltGr': 'AltGr',
+ 'Multi_key': 'Multi key',
+ 'SingleCandidate': 'Single Candidate',
+ 'Mode_switch': 'Mode switch',
+ 'Dead_Grave': '`',
+ 'Dead_Acute': '´',
+ 'Dead_Circumflex': '^',
+ 'Dead_Tilde': '~',
+ 'Dead_Macron': '¯',
+ 'Dead_Breve': '˘',
+ 'Dead_Abovedot': '˙',
+ 'Dead_Diaeresis': '¨',
+ 'Dead_Abovering': '˚',
+ 'Dead_Doubleacute': '˝',
+ 'Dead_Caron': 'ˇ',
+ 'Dead_Cedilla': '¸',
+ 'Dead_Ogonek': '˛',
+ 'Dead_Iota': 'Iota',
+ 'Dead_Voiced_Sound': 'Voiced Sound',
+ 'Dead_Semivoiced_Sound': 'Semivoiced Sound',
+ 'Dead_Belowdot': 'Belowdot',
+ 'Dead_Hook': 'Hook',
+ 'Dead_Horn': 'Horn',
+
+ 'Memo': 'Memo',
+ 'ToDoList': 'To Do List',
+ 'Calendar': 'Calendar',
+ 'ContrastAdjust': 'Contrast Adjust',
+ 'LaunchG': 'Launch (G)',
+ 'LaunchH': 'Launch (H)',
+
+ 'MediaLast': 'Media Last',
+
+ 'unknown': 'Unknown',
+
+ # For some keys, we just want a different name
+ 'Escape': 'Escape',
+ }
+ # We now build our real special_names dict from the string mapping above.
+ # The reason we don't do this directly is that certain Qt versions don't
+ # have all the keys, so we want to ignore AttributeErrors.
+ special_names = {}
+ for k, v in special_names_str.items():
+ try:
+ special_names[getattr(Qt, 'Key_' + k)] = v
+ except AttributeError:
+ pass
+ special_names[0x0] = 'nil'
+
+ if key in special_names:
+ return special_names[key]
+
+ result = QKeySequence(key).toString()
+ _check_valid_utf8(result, key)
+ return result
+
+
+def _modifiers_to_string(modifiers):
+ """Convert the given Qt::KeyboardModifiers to a string.
+
+ Handles Qt.GroupSwitchModifier because Qt doesn't handle that as a
+ modifier.
+ """
+ _assert_plain_modifier(modifiers)
+ if modifiers & Qt.GroupSwitchModifier:
+ modifiers &= ~Qt.GroupSwitchModifier
+ result = 'AltGr+'
+ else:
+ result = ''
+
+ result += QKeySequence(modifiers).toString()
+
+ _check_valid_utf8(result, modifiers)
+ return result
+
+
+class KeyParseError(Exception):
+
+ """Raised by _parse_single_key/parse_keystring on parse errors."""
+
+ def __init__(self, keystr, error):
+ if keystr is None:
+ msg = "Could not parse keystring: {}".format(error)
+ else:
+ msg = "Could not parse {!r}: {}".format(keystr, error)
+ super().__init__(msg)
+
+
+def _parse_keystring(keystr):
+ key = ''
+ special = False
+ for c in keystr:
+ if c == '>':
+ if special:
+ yield _parse_special_key(key)
+ key = ''
+ special = False
+ else:
+ yield '>'
+ assert not key, key
+ elif c == '<':
+ special = True
+ elif special:
+ key += c
+ else:
+ yield _parse_single_key(c)
+ if special:
+ yield '<'
+ for c in key:
+ yield _parse_single_key(c)
+
+
+def _parse_special_key(keystr):
+ """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q.
+
+ Args:
+ keystr: The key combination as a string.
+
+ Return:
+ The normalized keystring.
+ """
+ keystr = keystr.lower()
+ replacements = (
+ ('control', 'ctrl'),
+ ('windows', 'meta'),
+ ('mod1', 'alt'),
+ ('mod4', 'meta'),
+ ('less', '<'),
+ ('greater', '>'),
+ )
+ for (orig, repl) in replacements:
+ keystr = keystr.replace(orig, repl)
+
+ for mod in ['ctrl', 'meta', 'alt', 'shift', 'num']:
+ keystr = keystr.replace(mod + '-', mod + '+')
+ return keystr
+
+
+def _parse_single_key(keystr):
+ """Get a keystring for QKeySequence for a single key."""
+ return 'Shift+' + keystr if keystr.isupper() else keystr
+
+
+@attr.s
+class KeyInfo:
+
+ """A key with optional modifiers.
+
+ Attributes:
+ key: A Qt::Key member.
+ modifiers: A Qt::KeyboardModifiers enum value.
+ """
+
+ key = attr.ib()
+ modifiers = attr.ib()
+
+ @classmethod
+ def from_event(cls, e):
+ return cls(e.key(), e.modifiers())
+
+ def __str__(self):
+ """Convert this KeyInfo to a meaningful name.
+
+ Return:
+ A name of the key (combination) as a string.
+ """
+ key_string = _key_to_string(self.key)
+ modifiers = int(self.modifiers)
+
+ if self.key in _MODIFIER_MAP:
+ # Don't return e.g. <Shift+Shift>
+ modifiers &= ~_MODIFIER_MAP[self.key]
+ elif _is_printable(self.key):
+ # "normal" binding
+ if not key_string: # pragma: no cover
+ raise ValueError("Got empty string for key 0x{:x}!"
+ .format(self.key))
+
+ assert len(key_string) == 1, key_string
+ if self.modifiers == Qt.ShiftModifier:
+ assert not is_special(self.key, self.modifiers)
+ return key_string.upper()
+ elif self.modifiers == Qt.NoModifier:
+ assert not is_special(self.key, self.modifiers)
+ return key_string.lower()
+ else:
+ # Use special binding syntax, but <Ctrl-a> instead of <Ctrl-A>
+ key_string = key_string.lower()
+
+ # "special" binding
+ assert is_special(self.key, self.modifiers)
+ modifier_string = _modifiers_to_string(modifiers)
+ return '<{}{}>'.format(modifier_string, key_string)
+
+ def text(self):
+ """Get the text which would be displayed when pressing this key."""
+ control = {
+ Qt.Key_Space: ' ',
+ Qt.Key_Tab: '\t',
+ Qt.Key_Backspace: '\b',
+ Qt.Key_Return: '\r',
+ Qt.Key_Enter: '\r',
+ Qt.Key_Escape: '\x1b',
+ }
+
+ if self.key in control:
+ return control[self.key]
+ elif not _is_printable(self.key):
+ return ''
+
+ text = QKeySequence(self.key).toString()
+ if not self.modifiers & Qt.ShiftModifier:
+ text = text.lower()
+ return text
+
+ def to_event(self, typ=QEvent.KeyPress):
+ """Get a QKeyEvent from this KeyInfo."""
+ return QKeyEvent(typ, self.key, self.modifiers, self.text())
+
+ def to_int(self):
+ """Get the key as an integer (with key/modifiers)."""
+ return int(self.key) | int(self.modifiers)
+
+
+class KeySequence:
+
+ """A sequence of key presses.
+
+ This internally uses chained QKeySequence objects and exposes a nicer
+ interface over it.
+
+ NOTE: While private members of this class are in theory mutable, they must
+ not be mutated in order to ensure consistent hashing.
+
+ Attributes:
+ _sequences: A list of QKeySequence
+
+ Class attributes:
+ _MAX_LEN: The maximum amount of keys in a QKeySequence.
+ """
+
+ _MAX_LEN = 4
+
+ def __init__(self, *keys):
+ self._sequences = []
+ for sub in utils.chunk(keys, self._MAX_LEN):
+ sequence = QKeySequence(*sub)
+ self._sequences.append(sequence)
+ if keys:
+ assert self
+ self._validate()
+
+ def __str__(self):
+ parts = []
+ for info in self:
+ parts.append(str(info))
+ return ''.join(parts)
+
+ def __iter__(self):
+ """Iterate over KeyInfo objects."""
+ for key_and_modifiers in self._iter_keys():
+ key = int(key_and_modifiers) & ~Qt.KeyboardModifierMask
+ modifiers = Qt.KeyboardModifiers(int(key_and_modifiers) &
+ Qt.KeyboardModifierMask)
+ yield KeyInfo(key=key, modifiers=modifiers)
+
+ def __repr__(self):
+ return utils.get_repr(self, keys=str(self))
+
+ def __lt__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences < other._sequences
+
+ def __gt__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences > other._sequences
+
+ def __le__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences <= other._sequences
+
+ def __ge__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences >= other._sequences
+
+ def __eq__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences == other._sequences
+
+ def __ne__(self, other):
+ # pylint: disable=protected-access
+ return self._sequences != other._sequences
+
+ def __hash__(self):
+ return hash(tuple(self._sequences))
+
+ def __len__(self):
+ return sum(len(seq) for seq in self._sequences)
+
+ def __bool__(self):
+ return bool(self._sequences)
+
+ def __getitem__(self, item):
+ if isinstance(item, slice):
+ keys = list(self._iter_keys())
+ return self.__class__(*keys[item])
+ else:
+ infos = list(self)
+ return infos[item]
+
+ def _iter_keys(self):
+ return itertools.chain.from_iterable(self._sequences)
+
+ def _validate(self, keystr=None):
+ for info in self:
+ if info.key < Qt.Key_Space or info.key >= Qt.Key_unknown:
+ raise KeyParseError(keystr, "Got invalid key!")
+
+ for seq in self._sequences:
+ if not seq:
+ raise KeyParseError(keystr, "Got invalid key!")
+
+ def matches(self, other):
+ """Check whether the given KeySequence matches with this one.
+
+ We store multiple QKeySequences with <= 4 keys each, so we need to
+ match those pair-wise, and account for an unequal amount of sequences
+ as well.
+ """
+ # pylint: disable=protected-access
+
+ if len(self._sequences) > len(other._sequences):
+ # If we entered more sequences than there are in the config,
+ # there's no way there can be a match.
+ return QKeySequence.NoMatch
+
+ for entered, configured in zip(self._sequences, other._sequences):
+ # If we get NoMatch/PartialMatch in a sequence, we can abort there.
+ match = entered.matches(configured)
+ if match != QKeySequence.ExactMatch:
+ return match
+
+ # We checked all common sequences and they had an ExactMatch.
+ #
+ # If there's still more sequences configured than entered, that's a
+ # PartialMatch, as more keypresses can still follow and new sequences
+ # will appear which we didn't check above.
+ #
+ # If there's the same amount of sequences configured and entered,
+ # that's an EqualMatch.
+ if len(self._sequences) == len(other._sequences):
+ return QKeySequence.ExactMatch
+ elif len(self._sequences) < len(other._sequences):
+ return QKeySequence.PartialMatch
+ else:
+ raise utils.Unreachable("self={!r} other={!r}".format(self, other))
+
+ def append_event(self, ev):
+ """Create a new KeySequence object with the given QKeyEvent added."""
+ key = ev.key()
+ modifiers = ev.modifiers()
+
+ _assert_plain_key(key)
+ _assert_plain_modifier(modifiers)
+
+ if key == 0x0:
+ raise KeyParseError(None, "Got nil key!")
+
+ # We always remove Qt.GroupSwitchModifier because QKeySequence has no
+ # way to mention that in a binding anyways...
+ modifiers &= ~Qt.GroupSwitchModifier
+
+ # We change Qt.Key_Backtab to Key_Tab here because nobody would
+ # configure "Shift-Backtab" in their config.
+ if modifiers & Qt.ShiftModifier and key == Qt.Key_Backtab:
+ key = Qt.Key_Tab
+
+ # We don't care about a shift modifier with symbols (Shift-: should
+ # match a : binding even though we typed it with a shift on an
+ # US-keyboard)
+ #
+ # However, we *do* care about Shift being involved if we got an
+ # upper-case letter, as Shift-A should match a Shift-A binding, but not
+ # an "a" binding.
+ #
+ # In addition, Shift also *is* relevant when other modifiers are
+ # involved. Shift-Ctrl-X should not be equivalent to Ctrl-X.
+ if (modifiers == Qt.ShiftModifier and
+ _is_printable(ev.key()) and
+ not ev.text().isupper()):
+ modifiers = Qt.KeyboardModifiers()
+
+ # On macOS, swap Ctrl and Meta back
+ # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-51293
+ if utils.is_mac:
+ if modifiers & Qt.ControlModifier and modifiers & Qt.MetaModifier:
+ pass
+ elif modifiers & Qt.ControlModifier:
+ modifiers &= ~Qt.ControlModifier
+ modifiers |= Qt.MetaModifier
+ elif modifiers & Qt.MetaModifier:
+ modifiers &= ~Qt.MetaModifier
+ modifiers |= Qt.ControlModifier
+
+ keys = list(self._iter_keys())
+ keys.append(key | int(modifiers))
+
+ return self.__class__(*keys)
+
+ def strip_modifiers(self):
+ """Strip optional modifiers from keys."""
+ modifiers = Qt.KeypadModifier
+ keys = [key & ~modifiers for key in self._iter_keys()]
+ return self.__class__(*keys)
+
+ def with_mappings(self, mappings):
+ """Get a new KeySequence with the given mappings applied."""
+ keys = []
+ for key in self._iter_keys():
+ key_seq = KeySequence(key)
+ if key_seq in mappings:
+ new_seq = mappings[key_seq]
+ assert len(new_seq) == 1
+ key = new_seq[0].to_int()
+ keys.append(key)
+ return self.__class__(*keys)
+
+ @classmethod
+ def parse(cls, keystr):
+ """Parse a keystring like <Ctrl-x> or xyz and return a KeySequence."""
+ # pylint: disable=protected-access
+ new = cls()
+ strings = list(_parse_keystring(keystr))
+ for sub in utils.chunk(strings, cls._MAX_LEN):
+ sequence = QKeySequence(', '.join(sub))
+ new._sequences.append(sequence)
+
+ if keystr:
+ assert new, keystr
+
+ # pylint: disable=protected-access
+ new._validate(keystr)
+ return new
diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py
index 4e9d78fb0..ffe780333 100644
--- a/qutebrowser/keyinput/modeman.py
+++ b/qutebrowser/keyinput/modeman.py
@@ -25,7 +25,7 @@ import attr
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QObject, QEvent
from PyQt5.QtWidgets import QApplication
-from qutebrowser.keyinput import modeparsers, keyparser
+from qutebrowser.keyinput import modeparsers
from qutebrowser.config import config
from qutebrowser.commands import cmdexc, cmdutils
from qutebrowser.utils import usertypes, log, objreg, utils
@@ -68,24 +68,30 @@ def init(win_id, parent):
modeman = ModeManager(win_id, parent)
objreg.register('mode-manager', modeman, scope='window', window=win_id)
keyparsers = {
- KM.normal: modeparsers.NormalKeyParser(win_id, modeman),
- KM.hint: modeparsers.HintKeyParser(win_id, modeman),
- KM.insert: keyparser.PassthroughKeyParser(win_id, 'insert', modeman),
- KM.passthrough: keyparser.PassthroughKeyParser(win_id, 'passthrough',
- modeman),
- KM.command: keyparser.PassthroughKeyParser(win_id, 'command', modeman),
- KM.prompt: keyparser.PassthroughKeyParser(win_id, 'prompt', modeman,
- warn=False),
- KM.yesno: modeparsers.PromptKeyParser(win_id, modeman),
- KM.caret: modeparsers.CaretKeyParser(win_id, modeman),
- KM.set_mark: modeparsers.RegisterKeyParser(win_id, KM.set_mark,
- modeman),
- KM.jump_mark: modeparsers.RegisterKeyParser(win_id, KM.jump_mark,
- modeman),
- KM.record_macro: modeparsers.RegisterKeyParser(win_id, KM.record_macro,
- modeman),
- KM.run_macro: modeparsers.RegisterKeyParser(win_id, KM.run_macro,
- modeman),
+ KM.normal:
+ modeparsers.NormalKeyParser(win_id, modeman),
+ KM.hint:
+ modeparsers.HintKeyParser(win_id, modeman),
+ KM.insert:
+ modeparsers.PassthroughKeyParser(win_id, 'insert', modeman),
+ KM.passthrough:
+ modeparsers.PassthroughKeyParser(win_id, 'passthrough', modeman),
+ KM.command:
+ modeparsers.PassthroughKeyParser(win_id, 'command', modeman),
+ KM.prompt:
+ modeparsers.PassthroughKeyParser(win_id, 'prompt', modeman),
+ KM.yesno:
+ modeparsers.PromptKeyParser(win_id, modeman),
+ KM.caret:
+ modeparsers.CaretKeyParser(win_id, modeman),
+ KM.set_mark:
+ modeparsers.RegisterKeyParser(win_id, KM.set_mark, modeman),
+ KM.jump_mark:
+ modeparsers.RegisterKeyParser(win_id, KM.jump_mark, modeman),
+ KM.record_macro:
+ modeparsers.RegisterKeyParser(win_id, KM.record_macro, modeman),
+ KM.run_macro:
+ modeparsers.RegisterKeyParser(win_id, KM.run_macro, modeman),
}
objreg.register('keyparsers', keyparsers, scope='window', window=win_id)
modeman.destroyed.connect(
@@ -149,11 +155,12 @@ class ModeManager(QObject):
def __repr__(self):
return utils.get_repr(self, mode=self.mode)
- def _eventFilter_keypress(self, event):
+ def _handle_keypress(self, event, *, dry_run=False):
"""Handle filtering of KeyPress events.
Args:
event: The KeyPress to examine.
+ dry_run: Don't actually handle the key, only filter it.
Return:
True if event should be filtered, False otherwise.
@@ -163,7 +170,7 @@ class ModeManager(QObject):
if curmode != usertypes.KeyMode.insert:
log.modes.debug("got keypress in mode {} - delegating to "
"{}".format(curmode, utils.qualname(parser)))
- handled = parser.handle(event)
+ match = parser.handle(event, dry_run=dry_run)
is_non_alnum = (
event.modifiers() not in [Qt.NoModifier, Qt.ShiftModifier] or
@@ -171,7 +178,7 @@ class ModeManager(QObject):
forward_unbound_keys = config.val.input.forward_unbound_keys
- if handled:
+ if match:
filter_this = True
elif (parser.passthrough or forward_unbound_keys == 'all' or
(forward_unbound_keys == 'auto' and is_non_alnum)):
@@ -179,20 +186,20 @@ class ModeManager(QObject):
else:
filter_this = True
- if not filter_this:
+ if not filter_this and not dry_run:
self._releaseevents_to_pass.add(KeyEvent.from_event(event))
if curmode != usertypes.KeyMode.insert:
focus_widget = QApplication.instance().focusWidget()
- log.modes.debug("handled: {}, forward_unbound_keys: {}, "
- "passthrough: {}, is_non_alnum: {} --> "
- "filter: {} (focused: {!r})".format(
- handled, forward_unbound_keys,
- parser.passthrough, is_non_alnum, filter_this,
- focus_widget))
+ log.modes.debug("match: {}, forward_unbound_keys: {}, "
+ "passthrough: {}, is_non_alnum: {}, dry_run: {} "
+ "--> filter: {} (focused: {!r})".format(
+ match, forward_unbound_keys,
+ parser.passthrough, is_non_alnum, dry_run,
+ filter_this, focus_widget))
return filter_this
- def _eventFilter_keyrelease(self, event):
+ def _handle_keyrelease(self, event):
"""Handle filtering of KeyRelease events.
Args:
@@ -315,7 +322,7 @@ class ModeManager(QObject):
raise ValueError("Can't leave normal mode!")
self.leave(self.mode, 'leave current')
- def eventFilter(self, event):
+ def handle_event(self, event):
"""Filter all events based on the currently set mode.
Also calls the real keypress handler.
@@ -331,8 +338,10 @@ class ModeManager(QObject):
return False
handlers = {
- QEvent.KeyPress: self._eventFilter_keypress,
- QEvent.KeyRelease: self._eventFilter_keyrelease,
+ QEvent.KeyPress: self._handle_keypress,
+ QEvent.KeyRelease: self._handle_keyrelease,
+ QEvent.ShortcutOverride:
+ functools.partial(self._handle_keypress, dry_run=True),
}
handler = handlers[event.type()]
return handler(event)
diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py
index b739d38a1..270590fff 100644
--- a/qutebrowser/keyinput/modeparsers.py
+++ b/qutebrowser/keyinput/modeparsers.py
@@ -27,10 +27,11 @@ import traceback
import enum
from PyQt5.QtCore import pyqtSlot, Qt
+from PyQt5.QtGui import QKeySequence
-from qutebrowser.commands import cmdexc
+from qutebrowser.commands import runners, cmdexc
from qutebrowser.config import config
-from qutebrowser.keyinput import keyparser
+from qutebrowser.keyinput import basekeyparser, keyutils
from qutebrowser.utils import usertypes, log, message, objreg, utils
@@ -38,7 +39,26 @@ STARTCHARS = ":/?"
LastPress = enum.Enum('LastPress', ['none', 'filtertext', 'keystring'])
-class NormalKeyParser(keyparser.CommandKeyParser):
+class CommandKeyParser(basekeyparser.BaseKeyParser):
+
+ """KeyChainParser for command bindings.
+
+ Attributes:
+ _commandrunner: CommandRunner instance.
+ """
+
+ def __init__(self, win_id, parent=None, supports_count=None):
+ super().__init__(win_id, parent, supports_count)
+ self._commandrunner = runners.CommandRunner(win_id)
+
+ def execute(self, cmdstr, count=None):
+ try:
+ self._commandrunner.run(cmdstr, count)
+ except cmdexc.Error as e:
+ message.error(str(e), stack=traceback.format_exc())
+
+
+class NormalKeyParser(CommandKeyParser):
"""KeyParser for normal mode with added STARTCHARS detection and more.
@@ -47,8 +67,7 @@ class NormalKeyParser(keyparser.CommandKeyParser):
"""
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=True,
- supports_chains=True)
+ super().__init__(win_id, parent, supports_count=True)
self._read_config('normal')
self._partial_timer = usertypes.Timer(self, 'partial-match')
self._partial_timer.setSingleShot(True)
@@ -59,11 +78,13 @@ class NormalKeyParser(keyparser.CommandKeyParser):
def __repr__(self):
return utils.get_repr(self)
- def _handle_single_key(self, e):
- """Override _handle_single_key to abort if the key is a startchar.
+ def handle(self, e, *, dry_run=False):
+ """Override to abort if the key is a startchar.
Args:
e: the KeyPressEvent from Qt.
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Return:
A self.Match member.
@@ -72,9 +93,11 @@ class NormalKeyParser(keyparser.CommandKeyParser):
if self._inhibited:
self._debug_log("Ignoring key '{}', because the normal mode is "
"currently inhibited.".format(txt))
- return self.Match.none
- match = super()._handle_single_key(e)
- if match == self.Match.partial:
+ return QKeySequence.NoMatch
+
+ match = super().handle(e, dry_run=dry_run)
+
+ if match == QKeySequence.PartialMatch and not dry_run:
timeout = config.val.input.partial_timeout
if timeout != 0:
self._partial_timer.setInterval(timeout)
@@ -96,9 +119,9 @@ class NormalKeyParser(keyparser.CommandKeyParser):
def _clear_partial_match(self):
"""Clear a partial keystring after a timeout."""
self._debug_log("Clearing partial keystring {}".format(
- self._keystring))
- self._keystring = ''
- self.keystring_updated.emit(self._keystring)
+ self._sequence))
+ self._sequence = keyutils.KeySequence()
+ self.keystring_updated.emit(str(self._sequence))
@pyqtSlot()
def _clear_inhibited(self):
@@ -123,22 +146,48 @@ class NormalKeyParser(keyparser.CommandKeyParser):
pass
-class PromptKeyParser(keyparser.CommandKeyParser):
+class PassthroughKeyParser(CommandKeyParser):
+
+ """KeyChainParser which passes through normal keys.
+
+ Used for insert/passthrough modes.
+
+ Attributes:
+ _mode: The mode this keyparser is for.
+ """
+
+ do_log = False
+ passthrough = True
+
+ def __init__(self, win_id, mode, parent=None):
+ """Constructor.
+
+ Args:
+ mode: The mode this keyparser is for.
+ parent: Qt parent.
+ warn: Whether to warn if an ignored key was bound.
+ """
+ super().__init__(win_id, parent)
+ self._read_config(mode)
+ self._mode = mode
+
+ def __repr__(self):
+ return utils.get_repr(self, mode=self._mode)
+
+
+class PromptKeyParser(CommandKeyParser):
"""KeyParser for yes/no prompts."""
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=False,
- supports_chains=True)
- # We don't want an extra section for this in the config, so we just
- # abuse the prompt section.
- self._read_config('prompt')
+ super().__init__(win_id, parent, supports_count=False)
+ self._read_config('yesno')
def __repr__(self):
return utils.get_repr(self)
-class HintKeyParser(keyparser.CommandKeyParser):
+class HintKeyParser(CommandKeyParser):
"""KeyChainParser for hints.
@@ -148,15 +197,14 @@ class HintKeyParser(keyparser.CommandKeyParser):
"""
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=False,
- supports_chains=True)
+ super().__init__(win_id, parent, supports_count=False)
self._filtertext = ''
self._last_press = LastPress.none
self._read_config('hint')
self.keystring_updated.connect(self.on_keystring_updated)
- def _handle_special_key(self, e):
- """Override _handle_special_key to handle string filtering.
+ def _handle_filter_key(self, e):
+ """Handle keys for string filtering.
Return True if the keypress has been handled, and False if not.
@@ -164,78 +212,75 @@ class HintKeyParser(keyparser.CommandKeyParser):
e: the KeyPressEvent from Qt.
Return:
- True if event has been handled, False otherwise.
+ A QKeySequence match.
"""
- log.keyboard.debug("Got special key 0x{:x} text {}".format(
+ log.keyboard.debug("Got filter key 0x{:x} text {}".format(
e.key(), e.text()))
hintmanager = objreg.get('hintmanager', scope='tab',
window=self._win_id, tab='current')
if e.key() == Qt.Key_Backspace:
log.keyboard.debug("Got backspace, mode {}, filtertext '{}', "
- "keystring '{}'".format(self._last_press,
- self._filtertext,
- self._keystring))
+ "sequence '{}'".format(self._last_press,
+ self._filtertext,
+ self._sequence))
if self._last_press == LastPress.filtertext and self._filtertext:
self._filtertext = self._filtertext[:-1]
hintmanager.filter_hints(self._filtertext)
- return True
- elif self._last_press == LastPress.keystring and self._keystring:
- self._keystring = self._keystring[:-1]
- self.keystring_updated.emit(self._keystring)
- if not self._keystring and self._filtertext:
+ return QKeySequence.ExactMatch
+ elif self._last_press == LastPress.keystring and self._sequence:
+ self._sequence = self._sequence[:-1]
+ self.keystring_updated.emit(str(self._sequence))
+ if not self._sequence and self._filtertext:
# Switch back to hint filtering mode (this can happen only
# in numeric mode after the number has been deleted).
hintmanager.filter_hints(self._filtertext)
self._last_press = LastPress.filtertext
- return True
+ return QKeySequence.ExactMatch
else:
- return super()._handle_special_key(e)
+ return QKeySequence.NoMatch
elif hintmanager.current_mode() != 'number':
- return super()._handle_special_key(e)
+ return QKeySequence.NoMatch
elif not e.text():
- return super()._handle_special_key(e)
+ return QKeySequence.NoMatch
else:
self._filtertext += e.text()
hintmanager.filter_hints(self._filtertext)
self._last_press = LastPress.filtertext
- return True
+ return QKeySequence.ExactMatch
- def handle(self, e):
+ def handle(self, e, *, dry_run=False):
"""Handle a new keypress and call the respective handlers.
Args:
e: the KeyPressEvent from Qt
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Returns:
True if the match has been handled, False otherwise.
"""
- match = self._handle_single_key(e)
- if match == self.Match.partial:
- self.keystring_updated.emit(self._keystring)
+ dry_run_match = super().handle(e, dry_run=True)
+ if dry_run:
+ return dry_run_match
+
+ if keyutils.is_special(e.key(), e.modifiers()):
+ log.keyboard.debug("Got special key, clearing keychain")
+ self.clear_keystring()
+
+ assert not dry_run
+ match = super().handle(e)
+
+ if match == QKeySequence.PartialMatch:
self._last_press = LastPress.keystring
- return True
- elif match == self.Match.definitive:
+ elif match == QKeySequence.ExactMatch:
self._last_press = LastPress.none
- return True
- elif match == self.Match.other:
- return None
- elif match == self.Match.none:
+ elif match == QKeySequence.NoMatch:
# We couldn't find a keychain so we check if it's a special key.
- return self._handle_special_key(e)
+ return self._handle_filter_key(e)
else:
raise ValueError("Got invalid match type {}!".format(match))
- def execute(self, cmdstr, keytype, count=None):
- """Handle a completed keychain."""
- if not isinstance(keytype, self.Type):
- raise TypeError("Type {} is no Type member!".format(keytype))
- if keytype == self.Type.chain:
- hintmanager = objreg.get('hintmanager', scope='tab',
- window=self._win_id, tab='current')
- hintmanager.handle_partial_key(cmdstr)
- else:
- # execute as command
- super().execute(cmdstr, keytype, count)
+ return match
def update_bindings(self, strings, preserve_filter=False):
"""Update bindings when the hint strings changed.
@@ -245,7 +290,9 @@ class HintKeyParser(keyparser.CommandKeyParser):
preserve_filter: Whether to keep the current value of
`self._filtertext`.
"""
- self.bindings = {s: s for s in strings}
+ self._read_config()
+ self.bindings.update({keyutils.KeySequence.parse(s):
+ 'follow-hint -s ' + s for s in strings})
if not preserve_filter:
self._filtertext = ''
@@ -257,19 +304,18 @@ class HintKeyParser(keyparser.CommandKeyParser):
hintmanager.handle_partial_key(keystr)
-class CaretKeyParser(keyparser.CommandKeyParser):
+class CaretKeyParser(CommandKeyParser):
"""KeyParser for caret mode."""
passthrough = True
def __init__(self, win_id, parent=None):
- super().__init__(win_id, parent, supports_count=True,
- supports_chains=True)
+ super().__init__(win_id, parent, supports_count=True)
self._read_config('caret')
-class RegisterKeyParser(keyparser.CommandKeyParser):
+class RegisterKeyParser(CommandKeyParser):
"""KeyParser for modes that record a register key.
@@ -279,28 +325,30 @@ class RegisterKeyParser(keyparser.CommandKeyParser):
"""
def __init__(self, win_id, mode, parent=None):
- super().__init__(win_id, parent, supports_count=False,
- supports_chains=False)
+ super().__init__(win_id, parent, supports_count=False)
self._mode = mode
self._read_config('register')
- def handle(self, e):
+ def handle(self, e, *, dry_run=False):
"""Override handle to always match the next key and use the register.
Args:
e: the KeyPressEvent from Qt.
+ dry_run: Don't actually execute anything, only check whether there
+ would be a match.
Return:
True if event has been handled, False otherwise.
"""
- if super().handle(e):
- return True
+ match = super().handle(e, dry_run=dry_run)
+ if match or dry_run:
+ return match
- key = e.text()
-
- if key == '' or utils.keyevent_to_string(e) is None:
+ if keyutils.is_special(e.key(), e.modifiers()):
# this is not a proper register key, let it pass and keep going
- return False
+ return QKeySequence.NoMatch
+
+ key = e.text()
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
@@ -322,5 +370,4 @@ class RegisterKeyParser(keyparser.CommandKeyParser):
message.error(str(err), stack=traceback.format_exc())
self.request_leave.emit(self._mode, "valid register key", True)
-
- return True
+ return QKeySequence.ExactMatch
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
index 18b349d5d..482291253 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -327,7 +327,7 @@ class MainWindow(QWidget):
self.tabbed_browser)
objreg.register('command-dispatcher', dispatcher, scope='window',
window=self.win_id)
- self.tabbed_browser.destroyed.connect(
+ self.tabbed_browser.widget.destroyed.connect(
functools.partial(objreg.delete, 'command-dispatcher',
scope='window', window=self.win_id))
@@ -347,10 +347,10 @@ class MainWindow(QWidget):
def _add_widgets(self):
"""Add or readd all widgets to the VBox."""
- self._vbox.removeWidget(self.tabbed_browser)
+ self._vbox.removeWidget(self.tabbed_browser.widget)
self._vbox.removeWidget(self._downloadview)
self._vbox.removeWidget(self.status)
- widgets = [self.tabbed_browser]
+ widgets = [self.tabbed_browser.widget]
downloads_position = config.val.downloads.position
if downloads_position == 'top':
@@ -469,7 +469,7 @@ class MainWindow(QWidget):
self.tabbed_browser.cur_scroll_perc_changed.connect(
status.percentage.set_perc)
- self.tabbed_browser.tab_index_changed.connect(
+ self.tabbed_browser.widget.tab_index_changed.connect(
status.tabindex.on_tab_index_changed)
self.tabbed_browser.cur_url_changed.connect(status.url.set_url)
@@ -518,7 +518,7 @@ class MainWindow(QWidget):
super().resizeEvent(e)
self._update_overlay_geometries()
self._downloadview.updateGeometry()
- self.tabbed_browser.tabBar().refresh()
+ self.tabbed_browser.widget.tabBar().refresh()
def showEvent(self, e):
"""Extend showEvent to register us as the last-visible-main-window.
@@ -547,7 +547,7 @@ class MainWindow(QWidget):
if crashsignal.is_crashing:
e.accept()
return
- tab_count = self.tabbed_browser.count()
+ tab_count = self.tabbed_browser.widget.count()
download_model = objreg.get('download-model', scope='window',
window=self.win_id)
download_count = download_model.running_downloads()
diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py
index 931d32654..f7af28440 100644
--- a/qutebrowser/mainwindow/prompt.py
+++ b/qutebrowser/mainwindow/prompt.py
@@ -507,8 +507,8 @@ class _BasePrompt(QWidget):
self._key_grid = QGridLayout()
self._key_grid.setVerticalSpacing(0)
- # The bindings are all in the 'prompt' mode, even for yesno prompts
- all_bindings = config.key_instance.get_reverse_bindings_for('prompt')
+ all_bindings = config.key_instance.get_reverse_bindings_for(
+ self.KEY_MODE.name)
labels = []
for cmd, text in self._allowed_commands():
@@ -596,6 +596,8 @@ class FilenamePrompt(_BasePrompt):
if config.val.prompt.filebrowser:
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
+ self._to_complete = ''
+
@pyqtSlot(str)
def _set_fileview_root(self, path, *, tabbed=False):
"""Set the root path for the file display."""
@@ -604,6 +606,9 @@ class FilenamePrompt(_BasePrompt):
separators += os.altsep
dirname = os.path.dirname(path)
+ basename = os.path.basename(path)
+ if not tabbed:
+ self._to_complete = ''
try:
if not path:
@@ -617,6 +622,7 @@ class FilenamePrompt(_BasePrompt):
elif os.path.isdir(dirname) and not tabbed:
# Input like /foo/ba -> show /foo contents
path = dirname
+ self._to_complete = basename
else:
return
except OSError:
@@ -634,7 +640,11 @@ class FilenamePrompt(_BasePrompt):
index: The QModelIndex of the selected element.
clicked: Whether the element was clicked.
"""
- path = os.path.normpath(self._file_model.filePath(index))
+ if index == QModelIndex():
+ path = os.path.join(self._file_model.rootPath(), self._to_complete)
+ else:
+ path = os.path.normpath(self._file_model.filePath(index))
+
if clicked:
path += os.sep
else:
@@ -696,6 +706,7 @@ class FilenamePrompt(_BasePrompt):
assert last_index.isValid()
idx = selmodel.currentIndex()
+
if not idx.isValid():
# No item selected yet
idx = last_index if which == 'prev' else first_index
@@ -709,10 +720,24 @@ class FilenamePrompt(_BasePrompt):
if not idx.isValid():
idx = last_index if which == 'prev' else first_index
+ idx = self._do_completion(idx, which)
+
selmodel.setCurrentIndex(
idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows)
self._insert_path(idx, clicked=False)
+ def _do_completion(self, idx, which):
+ filename = self._file_model.fileName(idx)
+ while not filename.startswith(self._to_complete) and idx.isValid():
+ if which == 'prev':
+ idx = self._file_view.indexAbove(idx)
+ else:
+ assert which == 'next', which
+ idx = self._file_view.indexBelow(idx)
+ filename = self._file_model.fileName(idx)
+
+ return idx
+
def _allowed_commands(self):
return [('prompt-accept', 'Accept'), ('leave-mode', 'Abort')]
diff --git a/qutebrowser/mainwindow/statusbar/backforward.py b/qutebrowser/mainwindow/statusbar/backforward.py
index 8ea60ee75..5e244cf8c 100644
--- a/qutebrowser/mainwindow/statusbar/backforward.py
+++ b/qutebrowser/mainwindow/statusbar/backforward.py
@@ -32,7 +32,7 @@ class Backforward(textbase.TextBase):
def on_tab_cur_url_changed(self, tabs):
"""Called on URL changes."""
- tab = tabs.currentWidget()
+ tab = tabs.widget.currentWidget()
if tab is None: # pragma: no cover
self.setText('')
self.hide()
diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py
index 8057bfdb8..9efc26858 100644
--- a/qutebrowser/mainwindow/statusbar/bar.py
+++ b/qutebrowser/mainwindow/statusbar/bar.py
@@ -268,7 +268,7 @@ class StatusBar(QWidget):
"""Get the currently displayed tab."""
window = objreg.get('tabbed-browser', scope='window',
window=self._win_id)
- return window.currentWidget()
+ return window.widget.currentWidget()
def set_mode_active(self, mode, val):
"""Setter for self.{insert,command,caret}_active.
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index df5ab5584..1c757ad18 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -22,7 +22,7 @@
import functools
import attr
-from PyQt5.QtWidgets import QSizePolicy
+from PyQt5.QtWidgets import QSizePolicy, QWidget
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl
from PyQt5.QtGui import QIcon
@@ -50,7 +50,7 @@ class TabDeletedError(Exception):
"""Exception raised when _tab_index is called for a deleted tab."""
-class TabbedBrowser(tabwidget.TabWidget):
+class TabbedBrowser(QWidget):
"""A TabWidget with QWebViews inside.
@@ -110,17 +110,18 @@ class TabbedBrowser(tabwidget.TabWidget):
new_tab = pyqtSignal(browsertab.AbstractTab, int)
def __init__(self, *, win_id, private, parent=None):
- super().__init__(win_id, parent)
+ super().__init__(parent)
+ self.widget = tabwidget.TabWidget(win_id, parent=self)
self._win_id = win_id
self._tab_insert_idx_left = 0
self._tab_insert_idx_right = -1
self.shutting_down = False
- self.tabCloseRequested.connect(self.on_tab_close_requested)
- self.new_tab_requested.connect(self.tabopen)
- self.currentChanged.connect(self.on_current_changed)
+ self.widget.tabCloseRequested.connect(self.on_tab_close_requested)
+ self.widget.new_tab_requested.connect(self.tabopen)
+ self.widget.currentChanged.connect(self.on_current_changed)
self.cur_load_started.connect(self.on_cur_load_started)
- self.cur_fullscreen_requested.connect(self.tabBar().maybe_hide)
- self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide)
+ self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._undo_stack = []
self._filter = signalfilter.SignalFilter(win_id, self)
self._now_focused = None
@@ -128,12 +129,12 @@ class TabbedBrowser(tabwidget.TabWidget):
self.search_options = {}
self._local_marks = {}
self._global_marks = {}
- self.default_window_icon = self.window().windowIcon()
+ self.default_window_icon = self.widget.window().windowIcon()
self.private = private
config.instance.changed.connect(self._on_config_changed)
def __repr__(self):
- return utils.get_repr(self, count=self.count())
+ return utils.get_repr(self, count=self.widget.count())
@pyqtSlot(str)
def _on_config_changed(self, option):
@@ -142,7 +143,7 @@ class TabbedBrowser(tabwidget.TabWidget):
elif option == 'window.title_format':
self._update_window_title()
elif option in ['tabs.title.format', 'tabs.title.format_pinned']:
- self._update_tab_titles()
+ self.widget.update_tab_titles()
def _tab_index(self, tab):
"""Get the index of a given tab.
@@ -150,7 +151,7 @@ class TabbedBrowser(tabwidget.TabWidget):
Raises TabDeletedError if the tab doesn't exist anymore.
"""
try:
- idx = self.indexOf(tab)
+ idx = self.widget.indexOf(tab)
except RuntimeError as e:
log.webview.debug("Got invalid tab ({})!".format(e))
raise TabDeletedError(e)
@@ -166,8 +167,8 @@ class TabbedBrowser(tabwidget.TabWidget):
iterating over the list.
"""
widgets = []
- for i in range(self.count()):
- widget = self.widget(i)
+ for i in range(self.widget.count()):
+ widget = self.widget.widget(i)
if widget is None:
log.webview.debug("Got None-widget in tabbedbrowser!")
else:
@@ -186,16 +187,16 @@ class TabbedBrowser(tabwidget.TabWidget):
if field is not None and ('{' + field + '}') not in title_format:
return
- idx = self.currentIndex()
+ idx = self.widget.currentIndex()
if idx == -1:
# (e.g. last tab removed)
log.webview.debug("Not updating window title because index is -1")
return
- fields = self.get_tab_fields(idx)
+ fields = self.widget.get_tab_fields(idx)
fields['id'] = self._win_id
title = title_format.format(**fields)
- self.window().setWindowTitle(title)
+ self.widget.window().setWindowTitle(title)
def _connect_tab_signals(self, tab):
"""Set up the needed signals for tab."""
@@ -247,8 +248,8 @@ class TabbedBrowser(tabwidget.TabWidget):
Return:
The current URL as QUrl.
"""
- idx = self.currentIndex()
- return super().tab_url(idx)
+ idx = self.widget.currentIndex()
+ return self.widget.tab_url(idx)
def shutdown(self):
"""Try to shut down all tabs cleanly."""
@@ -284,7 +285,7 @@ class TabbedBrowser(tabwidget.TabWidget):
new_undo: Whether the undo entry should be a new item in the stack.
"""
last_close = config.val.tabs.last_close
- count = self.count()
+ count = self.widget.count()
if last_close == 'ignore' and count == 1:
return
@@ -311,7 +312,7 @@ class TabbedBrowser(tabwidget.TabWidget):
new_undo: Whether the undo entry should be a new item in the stack.
crashed: Whether we're closing a tab with crashed renderer process.
"""
- idx = self.indexOf(tab)
+ idx = self.widget.indexOf(tab)
if idx == -1:
if crashed:
return
@@ -349,7 +350,7 @@ class TabbedBrowser(tabwidget.TabWidget):
self._undo_stack[-1].append(entry)
tab.shutdown()
- self.removeTab(idx)
+ self.widget.removeTab(idx)
if not crashed:
# WORKAROUND for a segfault when we delete the crashed tab.
# see https://bugreports.qt.io/browse/QTBUG-58698
@@ -362,14 +363,14 @@ class TabbedBrowser(tabwidget.TabWidget):
last_close = config.val.tabs.last_close
use_current_tab = False
if last_close in ['blank', 'startpage', 'default-page']:
- only_one_tab_open = self.count() == 1
- no_history = len(self.widget(0).history) == 1
+ only_one_tab_open = self.widget.count() == 1
+ no_history = len(self.widget.widget(0).history) == 1
urls = {
'blank': QUrl('about:blank'),
'startpage': config.val.url.start_pages[0],
'default-page': config.val.url.default_page,
}
- first_tab_url = self.widget(0).url()
+ first_tab_url = self.widget.widget(0).url()
last_close_urlstr = urls[last_close].toString().rstrip('/')
first_tab_urlstr = first_tab_url.toString().rstrip('/')
last_close_url_used = first_tab_urlstr == last_close_urlstr
@@ -378,15 +379,13 @@ class TabbedBrowser(tabwidget.TabWidget):
for entry in reversed(self._undo_stack.pop()):
if use_current_tab:
- self.openurl(entry.url, newtab=False)
- newtab = self.widget(0)
+ newtab = self.widget.widget(0)
use_current_tab = False
else:
- newtab = self.tabopen(entry.url, background=False,
- idx=entry.index)
+ newtab = self.tabopen(background=False, idx=entry.index)
newtab.history.deserialize(entry.history)
- self.set_tab_pinned(newtab, entry.pinned)
+ self.widget.set_tab_pinned(newtab, entry.pinned)
@pyqtSlot('QUrl', bool)
def openurl(self, url, newtab):
@@ -397,15 +396,15 @@ class TabbedBrowser(tabwidget.TabWidget):
newtab: True to open URL in a new tab, False otherwise.
"""
qtutils.ensure_valid(url)
- if newtab or self.currentWidget() is None:
+ if newtab or self.widget.currentWidget() is None:
self.tabopen(url, background=False)
else:
- self.currentWidget().openurl(url)
+ self.widget.currentWidget().openurl(url)
@pyqtSlot(int)
def on_tab_close_requested(self, idx):
"""Close a tab via an index."""
- tab = self.widget(idx)
+ tab = self.widget.widget(idx)
if tab is None:
log.webview.debug("Got invalid tab {} for index {}!".format(
tab, idx))
@@ -456,7 +455,7 @@ class TabbedBrowser(tabwidget.TabWidget):
"related {}, idx {}".format(
url, background, related, idx))
- if (config.val.tabs.tabs_are_windows and self.count() > 0 and
+ if (config.val.tabs.tabs_are_windows and self.widget.count() > 0 and
not ignore_tabs_are_windows):
window = mainwindow.MainWindow(private=self.private)
window.show()
@@ -466,12 +465,12 @@ class TabbedBrowser(tabwidget.TabWidget):
related=related)
tab = browsertab.create(win_id=self._win_id, private=self.private,
- parent=self)
+ parent=self.widget)
self._connect_tab_signals(tab)
if idx is None:
idx = self._get_new_tab_idx(related)
- self.insertTab(idx, tab, "")
+ self.widget.insertTab(idx, tab, "")
if url is not None:
tab.openurl(url)
@@ -482,10 +481,11 @@ class TabbedBrowser(tabwidget.TabWidget):
# Make sure the background tab has the correct initial size.
# With a foreground tab, it's going to be resized correctly by the
# layout anyways.
- tab.resize(self.currentWidget().size())
- self.tab_index_changed.emit(self.currentIndex(), self.count())
+ tab.resize(self.widget.currentWidget().size())
+ self.widget.tab_index_changed.emit(self.widget.currentIndex(),
+ self.widget.count())
else:
- self.setCurrentWidget(tab)
+ self.widget.setCurrentWidget(tab)
tab.show()
self.new_tab.emit(tab, idx)
@@ -530,13 +530,14 @@ class TabbedBrowser(tabwidget.TabWidget):
"""Update favicons when config was changed."""
for i, tab in enumerate(self.widgets()):
if config.val.tabs.favicons.show:
- self.setTabIcon(i, tab.icon())
+ self.widget.setTabIcon(i, tab.icon())
if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(tab.icon())
+ self.widget.window().setWindowIcon(tab.icon())
else:
- self.setTabIcon(i, QIcon())
+ self.widget.setTabIcon(i, QIcon())
if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(self.default_window_icon)
+ window = self.widget.window()
+ window.setWindowIcon(self.default_window_icon)
@pyqtSlot()
def on_load_started(self, tab):
@@ -550,15 +551,14 @@ class TabbedBrowser(tabwidget.TabWidget):
except TabDeletedError:
# We can get signals for tabs we already deleted...
return
- self._update_tab_title(idx)
+ self.widget.update_tab_title(idx)
if tab.data.keep_icon:
tab.data.keep_icon = False
else:
- self.setTabIcon(idx, QIcon())
if (config.val.tabs.tabs_are_windows and
config.val.tabs.favicons.show):
- self.window().setWindowIcon(self.default_window_icon)
- if idx == self.currentIndex():
+ self.widget.window().setWindowIcon(self.default_window_icon)
+ if idx == self.widget.currentIndex():
self._update_window_title()
@pyqtSlot()
@@ -589,8 +589,8 @@ class TabbedBrowser(tabwidget.TabWidget):
return
log.webview.debug("Changing title for idx {} to '{}'".format(
idx, text))
- self.set_page_title(idx, text)
- if idx == self.currentIndex():
+ self.widget.set_page_title(idx, text)
+ if idx == self.widget.currentIndex():
self._update_window_title()
@pyqtSlot(browsertab.AbstractTab, QUrl)
@@ -607,8 +607,8 @@ class TabbedBrowser(tabwidget.TabWidget):
# We can get signals for tabs we already deleted...
return
- if not self.page_title(idx):
- self.set_page_title(idx, url.toDisplayString())
+ if not self.widget.page_title(idx):
+ self.widget.set_page_title(idx, url.toDisplayString())
@pyqtSlot(browsertab.AbstractTab, QIcon)
def on_icon_changed(self, tab, icon):
@@ -627,23 +627,23 @@ class TabbedBrowser(tabwidget.TabWidget):
except TabDeletedError:
# We can get signals for tabs we already deleted...
return
- self.setTabIcon(idx, icon)
+ self.widget.setTabIcon(idx, icon)
if config.val.tabs.tabs_are_windows:
- self.window().setWindowIcon(icon)
+ self.widget.window().setWindowIcon(icon)
@pyqtSlot(usertypes.KeyMode)
def on_mode_entered(self, mode):
"""Save input mode when tabs.mode_on_change = restore."""
if (config.val.tabs.mode_on_change == 'restore' and
mode in modeman.INPUT_MODES):
- tab = self.currentWidget()
+ tab = self.widget.currentWidget()
if tab is not None:
tab.data.input_mode = mode
@pyqtSlot(usertypes.KeyMode)
def on_mode_left(self, mode):
"""Give focus to current tab if command mode was left."""
- widget = self.currentWidget()
+ widget = self.widget.currentWidget()
if widget is None:
return
if mode in [usertypes.KeyMode.command] + modeman.PROMPT_MODES:
@@ -660,7 +660,7 @@ class TabbedBrowser(tabwidget.TabWidget):
if idx == -1 or self.shutting_down:
# closing the last tab (before quitting) or shutting down
return
- tab = self.widget(idx)
+ tab = self.widget.widget(idx)
if tab is None:
log.webview.debug("on_current_changed got called with invalid "
"index {}".format(idx))
@@ -690,8 +690,8 @@ class TabbedBrowser(tabwidget.TabWidget):
self._now_focused = tab
self.current_tab_changed.emit(tab)
QTimer.singleShot(0, self._update_window_title)
- self._tab_insert_idx_left = self.currentIndex()
- self._tab_insert_idx_right = self.currentIndex() + 1
+ self._tab_insert_idx_left = self.widget.currentIndex()
+ self._tab_insert_idx_right = self.widget.currentIndex() + 1
@pyqtSlot()
def on_cmd_return_pressed(self):
@@ -709,9 +709,9 @@ class TabbedBrowser(tabwidget.TabWidget):
stop = config.val.colors.tabs.indicator.stop
system = config.val.colors.tabs.indicator.system
color = utils.interpolate_color(start, stop, perc, system)
- self.set_tab_indicator_color(idx, color)
- self._update_tab_title(idx)
- if idx == self.currentIndex():
+ self.widget.set_tab_indicator_color(idx, color)
+ self.widget.update_tab_title(idx)
+ if idx == self.widget.currentIndex():
self._update_window_title()
def on_load_finished(self, tab, ok):
@@ -728,23 +728,23 @@ class TabbedBrowser(tabwidget.TabWidget):
color = utils.interpolate_color(start, stop, 100, system)
else:
color = config.val.colors.tabs.indicator.error
- self.set_tab_indicator_color(idx, color)
- self._update_tab_title(idx)
- if idx == self.currentIndex():
+ self.widget.set_tab_indicator_color(idx, color)
+ self.widget.update_tab_title(idx)
+ if idx == self.widget.currentIndex():
self._update_window_title()
tab.handle_auto_insert_mode(ok)
@pyqtSlot()
def on_scroll_pos_changed(self):
"""Update tab and window title when scroll position changed."""
- idx = self.currentIndex()
+ idx = self.widget.currentIndex()
if idx == -1:
# (e.g. last tab removed)
log.webview.debug("Not updating scroll position because index is "
"-1")
return
self._update_window_title('scroll_pos')
- self._update_tab_title(idx, 'scroll_pos')
+ self.widget.update_tab_title(idx, 'scroll_pos')
def _on_renderer_process_terminated(self, tab, status, code):
"""Show an error when a renderer process terminated."""
@@ -777,7 +777,7 @@ class TabbedBrowser(tabwidget.TabWidget):
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698
message.error(msg)
self._remove_tab(tab, crashed=True)
- if self.count() == 0:
+ if self.widget.count() == 0:
self.tabopen(QUrl('about:blank'))
def resizeEvent(self, e):
@@ -814,7 +814,7 @@ class TabbedBrowser(tabwidget.TabWidget):
if key != "'":
message.error("Failed to set mark: url invalid")
return
- point = self.currentWidget().scroller.pos_px()
+ point = self.widget.currentWidget().scroller.pos_px()
if key.isupper():
self._global_marks[key] = point, url
@@ -835,7 +835,7 @@ class TabbedBrowser(tabwidget.TabWidget):
except qtutils.QtValueError:
urlkey = None
- tab = self.currentWidget()
+ tab = self.widget.currentWidget()
if key.isupper():
if key in self._global_marks:
diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py
index 965e5b219..abc6cedae 100644
--- a/qutebrowser/mainwindow/tabwidget.py
+++ b/qutebrowser/mainwindow/tabwidget.py
@@ -60,7 +60,7 @@ class TabWidget(QTabWidget):
self.setTabBar(bar)
bar.tabCloseRequested.connect(self.tabCloseRequested)
bar.tabMoved.connect(functools.partial(
- QTimer.singleShot, 0, self._update_tab_titles))
+ QTimer.singleShot, 0, self.update_tab_titles))
bar.currentChanged.connect(self._on_current_changed)
bar.new_tab_requested.connect(self._on_new_tab_requested)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
@@ -108,7 +108,7 @@ class TabWidget(QTabWidget):
bar.set_tab_data(idx, 'pinned', pinned)
tab.data.pinned = pinned
- self._update_tab_title(idx)
+ self.update_tab_title(idx)
def tab_indicator_color(self, idx):
"""Get the tab indicator color for the given index."""
@@ -117,13 +117,13 @@ class TabWidget(QTabWidget):
def set_page_title(self, idx, title):
"""Set the tab title user data."""
self.tabBar().set_tab_data(idx, 'page-title', title)
- self._update_tab_title(idx)
+ self.update_tab_title(idx)
def page_title(self, idx):
"""Get the tab title user data."""
return self.tabBar().page_title(idx)
- def _update_tab_title(self, idx, field=None):
+ def update_tab_title(self, idx, field=None):
"""Update the tab text for the given tab.
Args:
@@ -197,20 +197,20 @@ class TabWidget(QTabWidget):
fields['scroll_pos'] = scroll_pos
return fields
- def _update_tab_titles(self):
+ def update_tab_titles(self):
"""Update all texts."""
for idx in range(self.count()):
- self._update_tab_title(idx)
+ self.update_tab_title(idx)
def tabInserted(self, idx):
"""Update titles when a tab was inserted."""
super().tabInserted(idx)
- self._update_tab_titles()
+ self.update_tab_titles()
def tabRemoved(self, idx):
"""Update titles when a tab was removed."""
super().tabRemoved(idx)
- self._update_tab_titles()
+ self.update_tab_titles()
def addTab(self, page, icon_or_text, text_or_empty=None):
"""Override addTab to use our own text setting logic.
diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py
index c78d0848d..9649d27cc 100644
--- a/qutebrowser/misc/earlyinit.py
+++ b/qutebrowser/misc/earlyinit.py
@@ -172,6 +172,7 @@ def check_qt_version():
from PyQt5.QtCore import (qVersion, QT_VERSION, PYQT_VERSION,
PYQT_VERSION_STR)
from pkg_resources import parse_version
+ from qutebrowser.utils import log
if (QT_VERSION < 0x050701 or PYQT_VERSION < 0x050700 or
parse_version(qVersion()) < parse_version('5.7.1')):
text = ("Fatal error: Qt >= 5.7.1 and PyQt >= 5.7 are required, "
@@ -179,6 +180,10 @@ def check_qt_version():
PYQT_VERSION_STR))
_die(text)
+ if qVersion().startswith('5.8.'):
+ log.init.warning("Running qutebrowser with Qt 5.8 is untested and "
+ "unsupported!")
+
def check_ssl_support():
"""Check if SSL support is available."""
diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py
index 154660001..473f67c3e 100644
--- a/qutebrowser/misc/editor.py
+++ b/qutebrowser/misc/editor.py
@@ -42,6 +42,7 @@ class ExternalEditor(QObject):
_proc: The GUIProcess of the editor.
_watcher: A QFileSystemWatcher to watch the edited file for changes.
Only set if watch=True.
+ _content: The last-saved text of the editor.
Signals:
file_updated: The text in the edited file was updated.
@@ -112,19 +113,7 @@ class ExternalEditor(QObject):
if self._filename is not None:
raise ValueError("Already editing a file!")
try:
- # Close while the external process is running, as otherwise systems
- # with exclusive write access (e.g. Windows) may fail to update
- # the file from the external editor, see
- # https://github.com/qutebrowser/qutebrowser/issues/1767
- with tempfile.NamedTemporaryFile(
- # pylint: disable=bad-continuation
- mode='w', prefix='qutebrowser-editor-',
- encoding=config.val.editor.encoding,
- delete=False) as fobj:
- # pylint: enable=bad-continuation
- if text:
- fobj.write(text)
- self._filename = fobj.name
+ self._filename = self._create_tempfile(text, 'qutebrowser-editor-')
except OSError as e:
message.error("Failed to create initial file: {}".format(e))
return
@@ -134,6 +123,32 @@ class ExternalEditor(QObject):
line, column = self._calc_line_and_column(text, caret_position)
self._start_editor(line=line, column=column)
+ def backup(self):
+ """Create a backup if the content has changed from the original."""
+ if not self._content:
+ return
+ try:
+ fname = self._create_tempfile(self._content,
+ 'qutebrowser-editor-backup-')
+ message.info('Editor backup at {}'.format(fname))
+ except OSError as e:
+ message.error('Failed to create editor backup: {}'.format(e))
+
+ def _create_tempfile(self, text, prefix):
+ # Close while the external process is running, as otherwise systems
+ # with exclusive write access (e.g. Windows) may fail to update
+ # the file from the external editor, see
+ # https://github.com/qutebrowser/qutebrowser/issues/1767
+ with tempfile.NamedTemporaryFile(
+ # pylint: disable=bad-continuation
+ mode='w', prefix=prefix,
+ encoding=config.val.editor.encoding,
+ delete=False) as fobj:
+ # pylint: enable=bad-continuation
+ if text:
+ fobj.write(text)
+ return fobj.name
+
@pyqtSlot(str)
def _on_file_changed(self, path):
try:
diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py
index fad58da2d..11446aa40 100644
--- a/qutebrowser/misc/keyhintwidget.py
+++ b/qutebrowser/misc/keyhintwidget.py
@@ -34,6 +34,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt
from qutebrowser.config import config
from qutebrowser.utils import utils, usertypes
from qutebrowser.commands import cmdutils
+from qutebrowser.keyinput import keyutils
class KeyHintView(QLabel):
@@ -105,9 +106,8 @@ class KeyHintView(QLabel):
bindings_dict = config.key_instance.get_bindings_for(modename)
bindings = [(k, v) for (k, v) in sorted(bindings_dict.items())
- if k.startswith(prefix) and
- not utils.is_special_key(k) and
- not blacklisted(k) and
+ if keyutils.KeySequence.parse(prefix).matches(k) and
+ not blacklisted(str(k)) and
(takes_count(v) or not countstr)]
if not bindings:
@@ -120,7 +120,7 @@ class KeyHintView(QLabel):
suffix_color = html.escape(config.val.colors.keyhint.suffix.fg)
text = ''
- for key, cmd in bindings:
+ for seq, cmd in bindings:
text += (
"<tr>"
"<td>{}</td>"
@@ -130,7 +130,7 @@ class KeyHintView(QLabel):
).format(
html.escape(prefix),
suffix_color,
- html.escape(key[len(prefix):]),
+ html.escape(str(seq[len(prefix):])),
html.escape(cmd)
)
text = '<table>{}</table>'.format(text)
diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py
index 2e868e27c..ffdfc951a 100644
--- a/qutebrowser/misc/miscwidgets.py
+++ b/qutebrowser/misc/miscwidgets.py
@@ -25,8 +25,8 @@ from PyQt5.QtWidgets import (QLineEdit, QWidget, QHBoxLayout, QLabel,
from PyQt5.QtGui import QValidator, QPainter
from qutebrowser.config import config
-from qutebrowser.utils import utils, qtutils, log, usertypes
-from qutebrowser.misc import cmdhistory, objects
+from qutebrowser.utils import utils
+from qutebrowser.misc import cmdhistory
class MinimalLineEditMixin:
@@ -260,16 +260,6 @@ class WrapperLayout(QLayout):
self._widget = widget
container.setFocusProxy(widget)
widget.setParent(container)
- if (qtutils.version_check('5.8.0', exact=True, compiled=False) and
- objects.backend == usertypes.Backend.QtWebEngine and
- container.window() and
- container.window().windowHandle() and
- not container.window().windowHandle().isActive()):
- log.misc.debug("Calling QApplication::sync...")
- # WORKAROUND for:
- # https://bugreports.qt.io/browse/QTBUG-56652
- # https://codereview.qt-project.org/#/c/176113/5//ALL,unified
- QApplication.sync()
def unwrap(self):
self._widget.setParent(None)
@@ -293,8 +283,6 @@ class FullscreenNotification(QLabel):
bindings = all_bindings.get('fullscreen --leave')
if bindings:
key = bindings[0]
- if utils.is_special_key(key):
- key = key.strip('<>').capitalize()
self.setText("Press {} to exit fullscreen.".format(key))
else:
self.setText("Page is now fullscreen.")
diff --git a/qutebrowser/misc/pastebin.py b/qutebrowser/misc/pastebin.py
index 0f2ed8ce4..f317670ec 100644
--- a/qutebrowser/misc/pastebin.py
+++ b/qutebrowser/misc/pastebin.py
@@ -60,7 +60,7 @@ class PastebinClient(QObject):
self._client = client
self._api_url = api_url
- def paste(self, name, title, text, parent=None):
+ def paste(self, name, title, text, parent=None, private=False):
"""Paste the text into a pastebin and return the URL.
Args:
@@ -68,6 +68,7 @@ class PastebinClient(QObject):
title: The post title.
text: The text to post.
parent: The parent paste to reply to.
+ private: Whether to paste privately.
"""
data = {
'text': text,
@@ -77,6 +78,9 @@ class PastebinClient(QObject):
}
if parent is not None:
data['reply'] = parent
+ if private:
+ data['private'] = '1'
+
url = QUrl(urllib.parse.urljoin(self._api_url, 'create'))
self._client.post(url, data)
diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py
index a8a652dbb..dddf48b05 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -246,7 +246,7 @@ class SessionManager(QObject):
if tabbed_browser.private:
win_data['private'] = True
for i, tab in enumerate(tabbed_browser.widgets()):
- active = i == tabbed_browser.currentIndex()
+ active = i == tabbed_browser.widget.currentIndex()
win_data['tabs'].append(self._save_tab(tab, active))
data['windows'].append(win_data)
return data
@@ -427,11 +427,12 @@ class SessionManager(QObject):
if tab.get('active', False):
tab_to_focus = i
if new_tab.data.pinned:
- tabbed_browser.set_tab_pinned(new_tab, new_tab.data.pinned)
+ tabbed_browser.widget.set_tab_pinned(new_tab,
+ new_tab.data.pinned)
if tab_to_focus is not None:
- tabbed_browser.setCurrentIndex(tab_to_focus)
+ tabbed_browser.widget.setCurrentIndex(tab_to_focus)
if win.get('active', False):
- QTimer.singleShot(0, tabbed_browser.activateWindow)
+ QTimer.singleShot(0, tabbed_browser.widget.activateWindow)
if data['windows']:
self.did_load = True
diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py
index d2743d56e..4b55eb04e 100644
--- a/qutebrowser/misc/utilcmds.py
+++ b/qutebrowser/misc/utilcmds.py
@@ -185,7 +185,7 @@ def debug_cache_stats():
tabbed_browser = objreg.get('tabbed-browser', scope='window',
window='last-focused')
# pylint: disable=protected-access
- tab_bar = tabbed_browser.tabBar()
+ tab_bar = tabbed_browser.widget.tabBar()
tabbed_browser_info = tab_bar._minimum_tab_size_hint_helper.cache_info()
# pylint: enable=protected-access
diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py
index d4ce3368f..b06444f93 100644
--- a/qutebrowser/utils/jinja.py
+++ b/qutebrowser/utils/jinja.py
@@ -22,12 +22,10 @@
import os
import os.path
import contextlib
-import traceback
import mimetypes
import html
import jinja2
-import jinja2.exceptions
from PyQt5.QtCore import QUrl
from qutebrowser.utils import utils, urlutils, log
@@ -125,14 +123,7 @@ class Environment(jinja2.Environment):
def render(template, **kwargs):
"""Render the given template and pass the given arguments to it."""
- try:
- return environment.get_template(template).render(**kwargs)
- except jinja2.exceptions.UndefinedError:
- log.misc.exception("UndefinedError while rendering " + template)
- err_path = os.path.join('html', 'undef_error.html')
- err_template = utils.read_file(err_path)
- tb = traceback.format_exc()
- return err_template.format(pagename=template, traceback=tb)
+ return environment.get_template(template).render(**kwargs)
environment = Environment()
diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py
index 8d44a9eb5..17fc34b92 100644
--- a/qutebrowser/utils/objreg.py
+++ b/qutebrowser/utils/objreg.py
@@ -171,7 +171,7 @@ def _get_tab_registry(win_id, tab_id):
if tab_id == 'current':
tabbed_browser = get('tabbed-browser', scope='window', window=win_id)
- tab = tabbed_browser.currentWidget()
+ tab = tabbed_browser.widget.currentWidget()
if tab is None:
raise RegistryUnavailableError('window')
tab_id = tab.tab_id
diff --git a/qutebrowser/utils/urlmatch.py b/qutebrowser/utils/urlmatch.py
new file mode 100644
index 000000000..5d9afc13e
--- /dev/null
+++ b/qutebrowser/utils/urlmatch.py
@@ -0,0 +1,293 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""A Chromium-like URL matching pattern.
+
+See:
+https://developer.chrome.com/apps/match_patterns
+https://cs.chromium.org/chromium/src/extensions/common/url_pattern.cc
+https://cs.chromium.org/chromium/src/extensions/common/url_pattern.h
+"""
+
+import ipaddress
+import fnmatch
+import urllib.parse
+
+from qutebrowser.utils import utils, qtutils
+
+
+class ParseError(Exception):
+
+ """Raised when a pattern could not be parsed."""
+
+
+class UrlPattern:
+
+ """A Chromium-like URL matching pattern.
+
+ Class attributes:
+ _DEFAULT_PORTS: The default ports used for schemes which support ports.
+ _SCHEMES_WITHOUT_HOST: Schemes which don't need a host.
+
+ Attributes:
+ _pattern: The given pattern as string.
+ _match_all: Whether the pattern should match all URLs.
+ _match_subdomains: Whether the pattern should match subdomains of the
+ given host.
+ _scheme: The scheme to match to, or None to match any scheme.
+ Note that with Chromium, '*'/None only matches http/https and
+ not file/ftp. We deviate from that as per-URL settings aren't
+ security relevant.
+ _host: The host to match to, or None for any host.
+ _path: The path to match to, or None for any path.
+ _port: The port to match to as integer, or None for any port.
+ """
+
+ _DEFAULT_PORTS = {'https': 443, 'http': 80, 'ftp': 21}
+ _SCHEMES_WITHOUT_HOST = ['about', 'file', 'data', 'javascript']
+
+ def __init__(self, pattern):
+ # Make sure all attributes are initialized if we exit early.
+ self._pattern = pattern
+ self._match_all = False
+ self._match_subdomains = False
+ self._scheme = None
+ self._host = None
+ self._path = None
+ self._port = None
+
+ # > The special pattern <all_urls> matches any URL that starts with a
+ # > permitted scheme.
+ if pattern == '<all_urls>':
+ self._match_all = True
+ return
+
+ if '\0' in pattern:
+ raise ParseError("May not contain NUL byte")
+
+ pattern = self._fixup_pattern(pattern)
+
+ # We use urllib.parse instead of QUrl here because it can handle
+ # hosts with * in them.
+ try:
+ parsed = urllib.parse.urlparse(pattern)
+ except ValueError as e:
+ raise ParseError(str(e))
+
+ assert parsed is not None
+
+ self._init_scheme(parsed)
+ self._init_host(parsed)
+ self._init_path(parsed)
+ self._init_port(parsed)
+
+ def _to_tuple(self):
+ """Get a pattern with information used for __eq__/__hash__."""
+ return (self._match_all, self._match_subdomains, self._scheme,
+ self._host, self._path, self._port)
+
+ def __hash__(self):
+ return hash(self._to_tuple())
+
+ def __eq__(self, other):
+ if not isinstance(other, UrlPattern):
+ return NotImplemented
+ # pylint: disable=protected-access
+ return self._to_tuple() == other._to_tuple()
+
+ def __repr__(self):
+ return utils.get_repr(self, pattern=self._pattern, constructor=True)
+
+ def __str__(self):
+ return self._pattern
+
+ def _fixup_pattern(self, pattern):
+ """Make sure the given pattern is parseable by urllib.parse."""
+ if pattern.startswith('*:'): # Any scheme, but *:// is unparseable
+ pattern = 'any:' + pattern[2:]
+
+ schemes = tuple(s + ':' for s in self._SCHEMES_WITHOUT_HOST)
+ if '://' not in pattern and not pattern.startswith(schemes):
+ pattern = 'any://' + pattern
+
+ # Chromium handles file://foo like file:///foo
+ # FIXME This doesn't actually strip the hostname correctly.
+ if (pattern.startswith('file://') and
+ not pattern.startswith('file:///')):
+ pattern = 'file:///' + pattern[len("file://"):]
+
+ return pattern
+
+ def _init_scheme(self, parsed):
+ """Parse the scheme from the given URL.
+
+ Deviation from Chromium:
+ - We assume * when no scheme has been given.
+ """
+ assert parsed.scheme, parsed
+ if parsed.scheme == 'any':
+ self._scheme = None
+ return
+
+ self._scheme = parsed.scheme
+
+ def _init_path(self, parsed):
+ """Parse the path from the given URL.
+
+ Deviation from Chromium:
+ - We assume * when no path has been given.
+ """
+ if self._scheme == 'about' and not parsed.path.strip():
+ raise ParseError("Pattern without path")
+
+ if parsed.path == '/*':
+ self._path = None
+ elif parsed.path == '':
+ # When the user doesn't add a trailing slash, we assume the pattern
+ # matches any path.
+ self._path = None
+ else:
+ self._path = parsed.path
+
+ def _init_host(self, parsed):
+ """Parse the host from the given URL.
+
+ Deviation from Chromium:
+ - http://:1234/ is not a valid URL because it has no host.
+ """
+ if parsed.hostname is None or not parsed.hostname.strip():
+ if self._scheme not in self._SCHEMES_WITHOUT_HOST:
+ raise ParseError("Pattern without host")
+ assert self._host is None
+ return
+
+ # FIXME what about multiple dots?
+ host_parts = parsed.hostname.rstrip('.').split('.')
+ if host_parts[0] == '*':
+ host_parts = host_parts[1:]
+ self._match_subdomains = True
+
+ if not host_parts:
+ self._host = None
+ return
+
+ self._host = '.'.join(host_parts)
+
+ if self._host.endswith('.*'):
+ # Special case to have a nicer error
+ raise ParseError("TLD wildcards are not implemented yet")
+ elif '*' in self._host:
+ # Only * or *.foo is allowed as host.
+ raise ParseError("Invalid host wildcard")
+
+ def _init_port(self, parsed):
+ """Parse the port from the given URL.
+
+ Deviation from Chromium:
+ - We use None instead of "*" if there's no port filter.
+ """
+ if parsed.netloc.endswith(':*'):
+ # We can't access parsed.port as it tries to run int()
+ self._port = None
+ elif parsed.netloc.endswith(':'):
+ raise ParseError("Invalid port: Port is empty")
+ else:
+ try:
+ self._port = parsed.port
+ except ValueError as e:
+ raise ParseError("Invalid port: {}".format(e))
+
+ if (self._scheme not in list(self._DEFAULT_PORTS) + [None] and
+ self._port is not None):
+ raise ParseError("Ports are unsupported with {} scheme".format(
+ self._scheme))
+
+ def _matches_scheme(self, scheme):
+ return self._scheme is None or self._scheme == scheme
+
+ def _matches_host(self, host):
+ # FIXME what about multiple dots?
+ host = host.rstrip('.')
+
+ # If we have no host in the match pattern, that means that we're
+ # matching all hosts, which means we have a match no matter what the
+ # test host is.
+ # Contrary to Chromium, we don't need to check for
+ # self._match_subdomains, as we want to return True here for e.g.
+ # file:// as well.
+ if self._host is None:
+ return True
+
+ # If the hosts are exactly equal, we have a match.
+ if host == self._host:
+ return True
+
+ # Otherwise, we can only match if our match pattern matches subdomains.
+ if not self._match_subdomains:
+ return False
+
+ # We don't do subdomain matching against IP addresses, so we can give
+ # up now if the test host is an IP address.
+ if not utils.raises(ValueError, ipaddress.ip_address, host):
+ return False
+
+ # Check if the test host is a subdomain of our host.
+ if len(host) <= (len(self._host) + 1):
+ return False
+
+ if not host.endswith(self._host):
+ return False
+
+ return host[len(host) - len(self._host) - 1] == '.'
+
+ def _matches_port(self, scheme, port):
+ if port == -1 and scheme in self._DEFAULT_PORTS:
+ port = self._DEFAULT_PORTS[scheme]
+ return self._port is None or self._port == port
+
+ def _matches_path(self, path):
+ if self._path is None:
+ return True
+
+ # Match 'google.com' with 'google.com/'
+ if path + '/*' == self._path:
+ return True
+
+ # FIXME Chromium seems to have a more optimized glob matching which
+ # doesn't rely on regexes. Do we need that too?
+ return fnmatch.fnmatchcase(path, self._path)
+
+ def matches(self, qurl):
+ """Check if the pattern matches the given QUrl."""
+ qtutils.ensure_valid(qurl)
+
+ if self._match_all:
+ return True
+
+ if not self._matches_scheme(qurl.scheme()):
+ return False
+ # FIXME ignore for file:// like Chromium?
+ if not self._matches_host(qurl.host()):
+ return False
+ if not self._matches_port(qurl.scheme(), qurl.port()):
+ return False
+ if not self._matches_path(qurl.path()):
+ return False
+
+ return True
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 8312cd803..039d805f9 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -27,6 +27,7 @@ import operator
import collections.abc
import enum
+import attr
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QTimer
from qutebrowser.utils import log, qtutils, utils
@@ -394,3 +395,24 @@ class AbstractCertificateErrorWrapper:
def is_overridable(self):
raise NotImplementedError
+
+
+@attr.s
+class NavigationRequest:
+
+ """A request to navigate to the given URL."""
+
+ Type = enum.Enum('Type', [
+ 'link_clicked',
+ 'typed', # QtWebEngine only
+ 'form_submitted',
+ 'form_resubmitted', # QtWebKit only
+ 'back_forward',
+ 'reloaded',
+ 'other'
+ ])
+
+ url = attr.ib()
+ navigation_type = attr.ib()
+ is_main_frame = attr.ib()
+ accepted = attr.ib(default=True)
diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py
index 079866920..da1ddf085 100644
--- a/qutebrowser/utils/utils.py
+++ b/qutebrowser/utils/utils.py
@@ -26,17 +26,16 @@ import re
import sys
import enum
import json
-import collections
import datetime
import traceback
import functools
import contextlib
import socket
import shlex
+import glob
-import attr
-from PyQt5.QtCore import Qt, QUrl
-from PyQt5.QtGui import QKeySequence, QColor, QClipboard, QDesktopServices
+from PyQt5.QtCore import QUrl
+from PyQt5.QtGui import QColor, QClipboard, QDesktopServices
from PyQt5.QtWidgets import QApplication
import pkg_resources
import yaml
@@ -48,11 +47,12 @@ except ImportError: # pragma: no cover
YAML_C_EXT = False
import qutebrowser
-from qutebrowser.utils import qtutils, log, debug
+from qutebrowser.utils import qtutils, log
fake_clipboard = None
log_clipboard = False
+_resource_cache = {}
is_mac = sys.platform.startswith('darwin')
is_linux = sys.platform.startswith('linux')
@@ -142,6 +142,15 @@ def compact_text(text, elidelength=None):
return out
+def preload_resources():
+ """Load resource files into the cache."""
+ for subdir, pattern in [('html', '*.html'), ('javascript', '*.js')]:
+ path = resource_filename(subdir)
+ for full_path in glob.glob(os.path.join(path, pattern)):
+ sub_path = '/'.join([subdir, os.path.basename(full_path)])
+ _resource_cache[sub_path] = read_file(sub_path)
+
+
def read_file(filename, binary=False):
"""Get the contents of a file contained with qutebrowser.
@@ -153,6 +162,9 @@ def read_file(filename, binary=False):
Return:
The file contents as string.
"""
+ if not binary and filename in _resource_cache:
+ return _resource_cache[filename]
+
if hasattr(sys, 'frozen'):
# PyInstaller doesn't support pkg_resources :(
# https://github.com/pyinstaller/pyinstaller/wiki/FAQ#misc
@@ -285,263 +297,6 @@ def format_size(size, base=1024, suffix=''):
return '{:.02f}{}{}'.format(size, prefixes[-1], suffix)
-def key_to_string(key):
- """Convert a Qt::Key member to a meaningful name.
-
- Args:
- key: A Qt::Key member.
-
- Return:
- A name of the key as a string.
- """
- special_names_str = {
- # Some keys handled in a weird way by QKeySequence::toString.
- # See https://bugreports.qt.io/browse/QTBUG-40030
- # Most are unlikely to be ever needed, but you never know ;)
- # For dead/combining keys, we return the corresponding non-combining
- # key, as that's easier to add to the config.
- 'Key_Blue': 'Blue',
- 'Key_Calendar': 'Calendar',
- 'Key_ChannelDown': 'Channel Down',
- 'Key_ChannelUp': 'Channel Up',
- 'Key_ContrastAdjust': 'Contrast Adjust',
- 'Key_Dead_Abovedot': '˙',
- 'Key_Dead_Abovering': '˚',
- 'Key_Dead_Acute': '´',
- 'Key_Dead_Belowdot': 'Belowdot',
- 'Key_Dead_Breve': '˘',
- 'Key_Dead_Caron': 'ˇ',
- 'Key_Dead_Cedilla': '¸',
- 'Key_Dead_Circumflex': '^',
- 'Key_Dead_Diaeresis': '¨',
- 'Key_Dead_Doubleacute': '˝',
- 'Key_Dead_Grave': '`',
- 'Key_Dead_Hook': 'Hook',
- 'Key_Dead_Horn': 'Horn',
- 'Key_Dead_Iota': 'Iota',
- 'Key_Dead_Macron': '¯',
- 'Key_Dead_Ogonek': '˛',
- 'Key_Dead_Semivoiced_Sound': 'Semivoiced Sound',
- 'Key_Dead_Tilde': '~',
- 'Key_Dead_Voiced_Sound': 'Voiced Sound',
- 'Key_Exit': 'Exit',
- 'Key_Green': 'Green',
- 'Key_Guide': 'Guide',
- 'Key_Info': 'Info',
- 'Key_LaunchG': 'LaunchG',
- 'Key_LaunchH': 'LaunchH',
- 'Key_MediaLast': 'MediaLast',
- 'Key_Memo': 'Memo',
- 'Key_MicMute': 'Mic Mute',
- 'Key_Mode_switch': 'Mode switch',
- 'Key_Multi_key': 'Multi key',
- 'Key_PowerDown': 'Power Down',
- 'Key_Red': 'Red',
- 'Key_Settings': 'Settings',
- 'Key_SingleCandidate': 'Single Candidate',
- 'Key_ToDoList': 'Todo List',
- 'Key_TouchpadOff': 'Touchpad Off',
- 'Key_TouchpadOn': 'Touchpad On',
- 'Key_TouchpadToggle': 'Touchpad toggle',
- 'Key_Yellow': 'Yellow',
- 'Key_Alt': 'Alt',
- 'Key_AltGr': 'AltGr',
- 'Key_Control': 'Control',
- 'Key_Direction_L': 'Direction L',
- 'Key_Direction_R': 'Direction R',
- 'Key_Hyper_L': 'Hyper L',
- 'Key_Hyper_R': 'Hyper R',
- 'Key_Meta': 'Meta',
- 'Key_Shift': 'Shift',
- 'Key_Super_L': 'Super L',
- 'Key_Super_R': 'Super R',
- 'Key_unknown': 'Unknown',
- }
- # We now build our real special_names dict from the string mapping above.
- # The reason we don't do this directly is that certain Qt versions don't
- # have all the keys, so we want to ignore AttributeErrors.
- special_names = {}
- for k, v in special_names_str.items():
- try:
- special_names[getattr(Qt, k)] = v
- except AttributeError:
- pass
- # Now we check if the key is any special one - if not, we use
- # QKeySequence::toString.
- try:
- return special_names[key]
- except KeyError:
- name = QKeySequence(key).toString()
- morphings = {
- 'Backtab': 'Tab',
- 'Esc': 'Escape',
- }
- if name in morphings:
- return morphings[name]
- else:
- return name
-
-
-def keyevent_to_string(e):
- """Convert a QKeyEvent to a meaningful name.
-
- Args:
- e: A QKeyEvent.
-
- Return:
- A name of the key (combination) as a string or
- None if only modifiers are pressed..
- """
- if is_mac:
- # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user
- # can use it in the config as expected. See:
- # https://github.com/qutebrowser/qutebrowser/issues/110
- # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys
- modmask2str = collections.OrderedDict([
- (Qt.MetaModifier, 'Ctrl'),
- (Qt.AltModifier, 'Alt'),
- (Qt.ControlModifier, 'Meta'),
- (Qt.ShiftModifier, 'Shift'),
- ])
- else:
- modmask2str = collections.OrderedDict([
- (Qt.ControlModifier, 'Ctrl'),
- (Qt.AltModifier, 'Alt'),
- (Qt.MetaModifier, 'Meta'),
- (Qt.ShiftModifier, 'Shift'),
- ])
- modifiers = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta,
- Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_Hyper_L,
- Qt.Key_Hyper_R, Qt.Key_Direction_L, Qt.Key_Direction_R)
- if e.key() in modifiers:
- # Only modifier pressed
- return None
- mod = e.modifiers()
- parts = []
- for (mask, s) in modmask2str.items():
- if mod & mask and s not in parts:
- parts.append(s)
- parts.append(key_to_string(e.key()))
- return normalize_keystr('+'.join(parts))
-
-
-@attr.s(repr=False)
-class KeyInfo:
-
- """Stores information about a key, like used in a QKeyEvent.
-
- Attributes:
- key: Qt::Key
- modifiers: Qt::KeyboardModifiers
- text: str
- """
-
- key = attr.ib()
- modifiers = attr.ib()
- text = attr.ib()
-
- def __repr__(self):
- if self.modifiers is None:
- modifiers = None
- else:
- #modifiers = qflags_key(Qt, self.modifiers)
- modifiers = hex(int(self.modifiers))
- return get_repr(self, constructor=True,
- key=debug.qenum_key(Qt, self.key),
- modifiers=modifiers, text=self.text)
-
-
-class KeyParseError(Exception):
-
- """Raised by _parse_single_key/parse_keystring on parse errors."""
-
- def __init__(self, keystr, error):
- super().__init__("Could not parse {!r}: {}".format(keystr, error))
-
-
-def is_special_key(keystr):
- """True if keystr is a 'special' keystring (e.g. <ctrl-x> or <space>)."""
- return keystr.startswith('<') and keystr.endswith('>')
-
-
-def _parse_single_key(keystr):
- """Convert a single key string to a (Qt.Key, Qt.Modifiers, text) tuple."""
- if is_special_key(keystr):
- # Special key
- keystr = keystr[1:-1]
- elif len(keystr) == 1:
- # vim-like key
- pass
- else:
- raise KeyParseError(keystr, "Expecting either a single key or a "
- "<Ctrl-x> like keybinding.")
-
- seq = QKeySequence(normalize_keystr(keystr), QKeySequence.PortableText)
- if len(seq) != 1:
- raise KeyParseError(keystr, "Got {} keys instead of 1.".format(
- len(seq)))
- result = seq[0]
-
- if result == Qt.Key_unknown:
- raise KeyParseError(keystr, "Got unknown key.")
-
- modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier |
- Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier |
- Qt.GroupSwitchModifier)
- assert Qt.Key_unknown & ~modifier_mask == Qt.Key_unknown
-
- modifiers = result & modifier_mask
- key = result & ~modifier_mask
-
- if len(keystr) == 1 and keystr.isupper():
- modifiers |= Qt.ShiftModifier
-
- assert key != 0, key
- key = Qt.Key(key)
- modifiers = Qt.KeyboardModifiers(modifiers)
-
- # Let's hope this is accurate...
- if len(keystr) == 1 and not modifiers:
- text = keystr
- elif len(keystr) == 1 and modifiers == Qt.ShiftModifier:
- text = keystr.upper()
- else:
- text = ''
-
- return KeyInfo(key, modifiers, text)
-
-
-def parse_keystring(keystr):
- """Parse a keystring like <Ctrl-x> or xyz and return a KeyInfo list."""
- if is_special_key(keystr):
- return [_parse_single_key(keystr)]
- else:
- return [_parse_single_key(char) for char in keystr]
-
-
-def normalize_keystr(keystr):
- """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q.
-
- Args:
- keystr: The key combination as a string.
-
- Return:
- The normalized keystring.
- """
- keystr = keystr.lower()
- replacements = (
- ('control', 'ctrl'),
- ('windows', 'meta'),
- ('mod1', 'alt'),
- ('mod4', 'meta'),
- )
- for (orig, repl) in replacements:
- keystr = keystr.replace(orig, repl)
- for mod in ['ctrl', 'meta', 'alt', 'shift']:
- keystr = keystr.replace(mod + '-', mod + '+')
- return keystr
-
-
class FakeIOStream(io.TextIOBase):
"""A fake file-like stream which calls a function for write-calls."""
@@ -915,3 +670,14 @@ def yaml_dump(data, f=None):
return None
else:
return yaml_data.decode('utf-8')
+
+
+def chunk(elems, n):
+ """Yield successive n-sized chunks from elems.
+
+ If elems % n != 0, the last chunk will be smaller.
+ """
+ if n < 1:
+ raise ValueError("n needs to be at least 1!")
+ for i in range(0, len(elems), n):
+ yield elems[i:i + n]
diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py
index 09a1a6efa..016adaa03 100644
--- a/qutebrowser/utils/version.py
+++ b/qutebrowser/utils/version.py
@@ -269,6 +269,8 @@ def _os_info():
else:
versioninfo = '.'.join(versioninfo)
osver = ', '.join([e for e in [release, versioninfo, machine] if e])
+ elif utils.is_posix:
+ osver = ' '.join(platform.uname())
else:
osver = '?'
lines.append('OS Version: {}'.format(osver))
@@ -305,7 +307,19 @@ def _pdfjs_version():
def _chromium_version():
- """Get the Chromium version for QtWebEngine."""
+ """Get the Chromium version for QtWebEngine.
+
+ This can also be checked by looking at this file with the right Qt tag:
+ https://github.com/qt/qtwebengine/blob/dev/tools/scripts/version_resolver.py#L41
+
+ Quick reference:
+ Qt 5.7: Chromium 49
+ Qt 5.8: Chromium 53
+ Qt 5.9: Chromium 56
+ Qt 5.10: Chromium 61
+ Qt 5.11: Chromium 63
+ Qt 5.12: Chromium 65 (?)
+ """
if QWebEngineProfile is None:
# This should never happen
return 'unavailable'
@@ -441,7 +455,13 @@ def opengl_vendor(): # pragma: no cover
vp = QOpenGLVersionProfile()
vp.setVersion(2, 0)
- vf = ctx.versionFunctions(vp)
+ try:
+ vf = ctx.versionFunctions(vp)
+ except ImportError as e:
+ log.init.debug("opengl_vendor: Importing version functions "
+ "failed: {}".format(e))
+ return None
+
if vf is None:
log.init.debug("opengl_vendor: Getting version functions failed!")
return None
@@ -453,7 +473,7 @@ def opengl_vendor(): # pragma: no cover
old_context.makeCurrent(old_surface)
-def pastebin_version():
+def pastebin_version(pbclient=None):
"""Pastebin the version and log the url to messages."""
def _yank_url(url):
utils.set_clipboard(url)
@@ -478,12 +498,13 @@ def pastebin_version():
http_client = httpclient.HTTPClient()
misc_api = pastebin.PastebinClient.MISC_API_URL
- pbclient = pastebin.PastebinClient(http_client, parent=app,
- api_url=misc_api)
+ pbclient = pbclient or pastebin.PastebinClient(http_client, parent=app,
+ api_url=misc_api)
pbclient.success.connect(_on_paste_version_success)
pbclient.error.connect(_on_paste_version_err)
pbclient.paste(getpass.getuser(),
"qute version info {}".format(qutebrowser.__version__),
- version())
+ version(),
+ private=True)
diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py
index 1827dc2a3..c4af174b2 100755
--- a/scripts/asciidoc2html.py
+++ b/scripts/asciidoc2html.py
@@ -85,9 +85,9 @@ class AsciiDoc:
# patch image links to use local copy
replacements = [
- ("https://qutebrowser.org/img/cheatsheet-big.png",
+ ("https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-big.png",
"qute://help/img/cheatsheet-big.png"),
- ("https://qutebrowser.org/img/cheatsheet-small.png",
+ ("https://raw.githubusercontent.com/qutebrowser/qutebrowser/master/doc/img/cheatsheet-small.png",
"qute://help/img/cheatsheet-small.png")
]
asciidoc_args = ['-a', 'source-highlighter=pygments']
diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py
index eba7610af..94f02dbf0 100755
--- a/scripts/dev/build_release.py
+++ b/scripts/dev/build_release.py
@@ -24,6 +24,7 @@
import os
import os.path
import sys
+import time
import glob
import shutil
import plistlib
@@ -195,6 +196,7 @@ def build_mac():
'MacOS', 'qutebrowser')
smoke_test(binary)
finally:
+ time.sleep(5)
subprocess.run(['hdiutil', 'detach', tmpdir])
except PermissionError as e:
print("Failed to remove tempdir: {}".format(e))
@@ -359,7 +361,7 @@ def github_upload(artifacts, tag):
repo = gh.repository('qutebrowser', 'qutebrowser')
release = None # to satisfy pylint
- for release in repo.iter_releases():
+ for release in repo.releases():
if release.tag_name == tag:
break
else:
diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py
index ea971c28c..32c5afc49 100644
--- a/scripts/dev/check_coverage.py
+++ b/scripts/dev/check_coverage.py
@@ -86,6 +86,8 @@ PERFECT_FILES = [
('tests/unit/keyinput/test_basekeyparser.py',
'keyinput/basekeyparser.py'),
+ ('tests/unit/keyinput/test_keyutils.py',
+ 'keyinput/keyutils.py'),
('tests/unit/misc/test_autoupdate.py',
'misc/autoupdate.py'),
@@ -143,6 +145,8 @@ PERFECT_FILES = [
'config/configinit.py'),
('tests/unit/config/test_configcommands.py',
'config/configcommands.py'),
+ ('tests/unit/config/test_configutils.py',
+ 'config/configutils.py'),
('tests/unit/utils/test_qtutils.py',
'utils/qtutils.py'),
@@ -164,11 +168,15 @@ PERFECT_FILES = [
'utils/error.py'),
('tests/unit/utils/test_javascript.py',
'utils/javascript.py'),
+ ('tests/unit/utils/test_urlmatch.py',
+ 'utils/urlmatch.py'),
(None,
'completion/models/util.py'),
('tests/unit/completion/test_models.py',
'completion/models/urlmodel.py'),
+ ('tests/unit/completion/test_models.py',
+ 'completion/models/configmodel.py'),
('tests/unit/completion/test_histcategory.py',
'completion/models/histcategory.py'),
('tests/unit/completion/test_listcategory.py',
diff --git a/scripts/dev/ci/travis_install.sh b/scripts/dev/ci/travis_install.sh
index 04b118b9b..20aa1c12d 100644
--- a/scripts/dev/ci/travis_install.sh
+++ b/scripts/dev/ci/travis_install.sh
@@ -83,7 +83,9 @@ elif [[ $TRAVIS_OS_NAME == osx ]]; then
sudo -H python get-pip.py
brew --version
- brew_install python3 qt5 pyqt5 libyaml
+ brew update
+ brew upgrade python
+ brew install qt5 pyqt5 libyaml
pip_install -r misc/requirements/requirements-tox.txt
python3 -m pip --version
@@ -101,5 +103,8 @@ case $TESTENV in
*)
pip_install pip
pip_install -r misc/requirements/requirements-tox.txt
+ if [[ $TESTENV == *-cov ]]; then
+ pip_install -r misc/requirements/requirements-codecov.txt
+ fi
;;
esac
diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py
index 6307e3a5c..cc00c3757 100755
--- a/scripts/dev/src2asciidoc.py
+++ b/scripts/dev/src2asciidoc.py
@@ -419,6 +419,10 @@ def _generate_setting_option(f, opt):
f.write(opt.description + "\n")
if opt.restart:
f.write("This setting requires a restart.\n")
+ if opt.supports_pattern:
+ f.write("\nThis setting supports URL patterns.\n")
+ if opt.no_autoconfig:
+ f.write("\nThis setting can only be set in config.py.\n")
f.write("\n")
typ = opt.typ.get_name().replace(',', '&#44;')
f.write('Type: <<types,{typ}>>\n'.format(typ=typ))
diff --git a/scripts/hostblock_blame.py b/scripts/hostblock_blame.py
new file mode 100644
index 000000000..2f68d2961
--- /dev/null
+++ b/scripts/hostblock_blame.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""Check by which hostblock list a host was blocked."""
+
+import sys
+import io
+import os
+import os.path
+import urllib.request
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
+from qutebrowser.browser import adblock
+from qutebrowser.config import configdata
+
+
+def main():
+ """Check by which hostblock list a host was blocked."""
+ if len(sys.argv) != 2:
+ print("Usage: {} <host>".format(sys.argv[0]), file=sys.stderr)
+ sys.exit(1)
+
+ configdata.init()
+
+ for url in configdata.DATA['content.host_blocking.lists'].default:
+ print("checking {}...".format(url))
+ raw_file = urllib.request.urlopen(url)
+ byte_io = io.BytesIO(raw_file.read())
+ f = adblock.get_fileobj(byte_io)
+ for line in f:
+ line = line.decode('utf-8')
+ if sys.argv[1] in line:
+ print("FOUND {} in {}:".format(sys.argv[1], url))
+ print(" " + line.rstrip())
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/keytester.py b/scripts/keytester.py
index 80260f6bf..ee5eb347c 100644
--- a/scripts/keytester.py
+++ b/scripts/keytester.py
@@ -25,7 +25,7 @@ Use python3 -m scripts.keytester to launch it.
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QHBoxLayout
-from qutebrowser.utils import utils
+from qutebrowser.keyinput import keyutils
class KeyWidget(QWidget):
@@ -41,7 +41,7 @@ class KeyWidget(QWidget):
def keyPressEvent(self, e):
"""Show pressed keys."""
lines = [
- str(utils.keyevent_to_string(e)),
+ str(keyutils.KeyInfo.from_event(e)),
'',
'key: 0x{:x}'.format(int(e.key())),
'modifiers: 0x{:x}'.format(int(e.modifiers())),
diff --git a/tests/conftest.py b/tests/conftest.py
index d9d5fc034..0b82bc7f6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -65,9 +65,16 @@ def _apply_platform_markers(config, item):
('issue2478', utils.is_windows and config.webengine,
"Broken with QtWebEngine on Windows"),
('issue3572',
- qtutils.version_check('5.10', compiled=False, exact=True) and
+ (qtutils.version_check('5.10', compiled=False, exact=True) or
+ qtutils.version_check('5.10.1', compiled=False, exact=True)) and
config.webengine and 'TRAVIS' in os.environ,
"Broken with QtWebEngine with Qt 5.10 on Travis"),
+ ('qtbug60673',
+ qtutils.version_check('5.8') and
+ not qtutils.version_check('5.10') and
+ config.webengine,
+ "Broken on webengine due to "
+ "https://bugreports.qt.io/browse/QTBUG-60673"),
('unicode_locale', sys.getfilesystemencoding() == 'ascii',
"Skipped because of ASCII locale"),
]
@@ -239,6 +246,8 @@ def apply_fake_os(monkeypatch, request):
elif name == 'linux':
linux = True
posix = True
+ elif name == 'posix':
+ posix = True
else:
raise ValueError("Invalid fake_os {}".format(name))
diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py
index d3d34097b..5226396a3 100644
--- a/tests/end2end/conftest.py
+++ b/tests/end2end/conftest.py
@@ -118,27 +118,6 @@ def _get_backend_tag(tag):
return pytest_marks[name](desc)
-def _get_dictionary_tag(tag):
- """Handle tags like must_have_dict=en-US for BDD tests."""
- dict_re = re.compile(r"""
- (?P<event>must_have_dict|cannot_have_dict)=(?P<dict>[a-z]{2}-[A-Z]{2})
- """, re.VERBOSE)
-
- match = dict_re.fullmatch(tag)
- if not match:
- return None
-
- event = match.group('event')
- dictionary = match.group('dict')
- has_dict = spell.local_filename(dictionary) is not None
- if event == 'must_have_dict':
- return pytest.mark.skipif(not has_dict, reason=tag)
- elif event == 'cannot_have_dict':
- return pytest.mark.skipif(has_dict, reason=tag)
- else:
- return None
-
-
if not getattr(sys, 'frozen', False):
def pytest_bdd_apply_tag(tag, function):
"""Handle custom tags for BDD tests.
@@ -146,7 +125,7 @@ if not getattr(sys, 'frozen', False):
This tries various functions, and if none knows how to handle this tag,
it returns None so it falls back to pytest-bdd's implementation.
"""
- funcs = [_get_version_tag, _get_backend_tag, _get_dictionary_tag]
+ funcs = [_get_version_tag, _get_backend_tag]
for func in funcs:
mark = func(tag)
if mark is not None:
diff --git a/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht b/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht
index 79bd1ae50..42a55ab7c 100644
--- a/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht
+++ b/tests/end2end/data/downloads/mhtml/simple/simple-webengine.mht
@@ -1,4 +1,5 @@
From: <Saved by Blink>
+Snapshot-Content-Location: http://localhost:(port)/data/downloads/mhtml/simple/simple.html
Subject: Simple MHTML test
Date: today
MIME-Version: 1.0
diff --git a/tests/end2end/data/hints/issue3711.html b/tests/end2end/data/hints/issue3711.html
new file mode 100644
index 000000000..6abceccc2
--- /dev/null
+++ b/tests/end2end/data/hints/issue3711.html
@@ -0,0 +1,13 @@
+<html>
+ <!-- https://github.com/qutebrowser/qutebrowser/issues/3711 -->
+ <head>
+ <title>Issue 3711</title>
+ </head>
+ <body>
+ <!--
+ Verify no hint error occurs when hinting input range elements in iframes on qt5.9
+ Possibly an issue in chrome.
+ -->
+ <input min="0" max="1" step="0.001" type="range">
+ </body>
+</html>
diff --git a/tests/end2end/data/hints/issue3711_frame.html b/tests/end2end/data/hints/issue3711_frame.html
new file mode 100644
index 000000000..37c5e5b71
--- /dev/null
+++ b/tests/end2end/data/hints/issue3711_frame.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Issue 3771 Parent Frame</title>
+ </head>
+ <body>
+ <iframe src="./issue3711.html"></iframe>
+ </body>
+</html>
diff --git a/tests/end2end/data/javascript/enabled.html b/tests/end2end/data/javascript/enabled.html
new file mode 100644
index 000000000..a25f02566
--- /dev/null
+++ b/tests/end2end/data/javascript/enabled.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script type="text/javascript">
+ function check_enabled() {
+ const elem = document.getElementById("status");
+ elem.innerHTML = "enabled";
+ console.log("JavaScript is enabled");
+ }
+ </script>
+ </head>
+ <body onload="check_enabled()">
+ <p>JavaScript is <span id="status">disabled</span></p>
+ <noscript>noscript tag</noscript>
+ </body>
+</html>
diff --git a/tests/end2end/data/javascript/localstorage.html b/tests/end2end/data/javascript/localstorage.html
index 28a11f24f..12c17bbc9 100644
--- a/tests/end2end/data/javascript/localstorage.html
+++ b/tests/end2end/data/javascript/localstorage.html
@@ -7,8 +7,10 @@
try {
localStorage.qute_test = "foo";
elem.innerHTML = "working";
+ console.log("local storage is working");
} catch (e) {
elem.innerHTML = "not working";
+ console.log("local storage is not working");
}
}
</script>
diff --git a/tests/end2end/features/caret.feature b/tests/end2end/features/caret.feature
index a3ff325f4..803016539 100644
--- a/tests/end2end/features/caret.feature
+++ b/tests/end2end/features/caret.feature
@@ -320,3 +320,28 @@ Feature: Caret mode
And the following tabs should be open:
- data/caret.html
- data/hello.txt (active)
+
+ # Search + caret mode
+
+ # https://bugreports.qt.io/browse/QTBUG-60673
+ @qtbug60673
+ Scenario: yanking a searched line
+ When I run :leave-mode
+ And I run :search fiv
+ And I wait for "search found fiv" in the log
+ And I run :enter-mode caret
+ And I run :move-to-end-of-line
+ And I run :yank selection
+ Then the clipboard should contain "five six"
+
+ @qtbug60673
+ Scenario: yanking a searched line with multiple matches
+ When I run :leave-mode
+ And I run :search w
+ And I wait for "search found w" in the log
+ And I run :search-next
+ And I wait for "next_result found w" in the log
+ And I run :enter-mode caret
+ And I run :move-to-end-of-line
+ And I run :yank selection
+ Then the clipboard should contain "wei drei"
diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature
index 391e88749..33535856c 100644
--- a/tests/end2end/features/editor.feature
+++ b/tests/end2end/features/editor.feature
@@ -128,6 +128,7 @@ Feature: Opening external editors
And I run :tab-close
And I kill the waiting editor
Then the error "Edited element vanished" should be shown
+ And the message "Editor backup at *" should be shown
# Could not get signals working on Windows
@posix
diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature
index dc14750e1..a1c4d0bde 100644
--- a/tests/end2end/features/hints.feature
+++ b/tests/end2end/features/hints.feature
@@ -207,7 +207,7 @@ Feature: Using hints
Scenario: Using :follow-hint inside an iframe
When I open data/hints/iframe.html
And I hint with args "links normal" and follow a
- Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged
+ Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, *" should be logged
Scenario: Using :follow-hint inside an iframe button
When I open data/hints/iframe_button.html
@@ -228,12 +228,12 @@ Feature: Using hints
And I hint with args "all normal" and follow a
And I run :scroll bottom
And I hint with args "links normal" and follow a
- Then "navigation request: url http://localhost:*/data/hello2.txt, type NavigationTypeLinkClicked, *" should be logged
+ Then "navigation request: url http://localhost:*/data/hello2.txt, type Type.link_clicked, *" should be logged
Scenario: Opening a link inside a specific iframe
When I open data/hints/iframe_target.html
And I hint with args "links normal" and follow a
- Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged
+ Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, *" should be logged
Scenario: Opening a link with specific target frame in a new tab
When I open data/hints/iframe_target.html
@@ -249,6 +249,11 @@ Feature: Using hints
And I hint with args "all current" and follow a
Then no crash should happen
+ Scenario: No error when hinting ranged input in frames
+ When I open data/hints/issue3711_frame.html
+ And I hint with args "all current" and follow a
+ Then no crash should happen
+
### hints.auto_follow.timeout
@not_mac @flaky
@@ -338,7 +343,7 @@ Feature: Using hints
And I set hints.auto_follow to unique-match
And I set hints.auto_follow_timeout to 0
And I hint with args "all"
- And I press the keys "ten pos"
+ And I press the keys "ten p"
Then data/numbers/11.txt should be loaded
Scenario: Scattering is ignored with number hints
diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature
index c74811b4b..637f4696c 100644
--- a/tests/end2end/features/javascript.feature
+++ b/tests/end2end/features/javascript.feature
@@ -130,6 +130,7 @@ Feature: Javascript stuff
And I run :tab-next
Then the window sizes should be the same
+ @flaky
Scenario: Have a GreaseMonkey script run at page start
When I have a GreaseMonkey file saved for document-start with noframes unset
And I run :greasemonkey-reload
@@ -151,3 +152,25 @@ Feature: Javascript stuff
And I run :greasemonkey-reload
And I open data/hints/iframe.html
Then the javascript message "Script is running on /data/hints/html/wrapped.html" should not be logged
+
+ Scenario: Per-URL localstorage setting
+ When I set content.local_storage to false
+ And I run :set -u http://localhost:*/data2/* content.local_storage true
+ And I open data/javascript/localstorage.html
+ And I wait for "[*] local storage is not working" in the log
+ And I open data2/javascript/localstorage.html
+ Then the javascript message "local storage is working" should be logged
+
+ Scenario: Per-URL JavaScript setting
+ When I set content.javascript.enabled to false
+ And I run :set -u http://localhost:*/data2/* content.javascript.enabled true
+ And I open data2/javascript/enabled.html
+ And I wait for "[*] JavaScript is enabled" in the log
+ And I open data/javascript/enabled.html
+ Then the page should contain the plaintext "JavaScript is disabled"
+
+ @qtwebkit_skip
+ Scenario: Error pages without JS enabled
+ When I set content.javascript.enabled to false
+ And I open 500 without waiting
+ Then "Showing error page for* 500" should be logged
diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature
index 5a857002a..fc62a7aa3 100644
--- a/tests/end2end/features/keyinput.feature
+++ b/tests/end2end/features/keyinput.feature
@@ -33,14 +33,13 @@ Feature: Keyboard input
Scenario: Forwarding special keys
When I open data/keyinput/log.html
And I set input.forward_unbound_keys to auto
- And I press the key "x"
- And I press the key "<F1>"
+ And I press the keys ",<F1>"
# <F1>
Then the javascript message "key press: 112" should be logged
And the javascript message "key release: 112" should be logged
- # x
- And the javascript message "key press: 88" should not be logged
- And the javascript message "key release: 88" should not be logged
+ # ,
+ And the javascript message "key press: 188" should not be logged
+ And the javascript message "key release: 188" should not be logged
Scenario: Forwarding no keys
When I open data/keyinput/log.html
@@ -54,7 +53,7 @@ Feature: Keyboard input
Scenario: :fake-key with an unparsable key
When I run :fake-key <blub>
- Then the error "Could not parse 'blub': Got unknown key." should be shown
+ Then the error "Could not parse '<blub>': Got invalid key!" should be shown
Scenario: :fake-key sending key to the website
When I open data/keyinput/log.html
diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature
index 8f21b7421..743cffd84 100644
--- a/tests/end2end/features/misc.feature
+++ b/tests/end2end/features/misc.feature
@@ -166,7 +166,7 @@ Feature: Various utility commands.
@qtwebkit_skip
Scenario: Inspector without --enable-webengine-inspector
When I run :inspector
- Then the error "Debugging is not enabled. See 'qutebrowser --help' for details." should be shown
+ Then the error "QtWebEngine inspector is not enabled. See 'qutebrowser --help' for details." should be shown
@no_xvfb @posix @qtwebengine_skip
Scenario: Inspector smoke test
@@ -436,6 +436,11 @@ Feature: Various utility commands.
And I run :message-info {clipboard}bar{url}
Then the message "foobarhttp://localhost:*/hello.txt" should be shown
+ Scenario: escaping {{url}} variable
+ When I open data/hello.txt
+ And I run :message-info foo{{url}}bar
+ Then the message "foo{url}bar" should be shown
+
@xfail_norun
Scenario: {url} in clipboard should not be expanded
When I open data/hello.txt
@@ -564,15 +569,3 @@ Feature: Various utility commands.
When I set up "simple" as block lists
And I run :adblock-update
Then the message "adblock: Read 1 hosts from 1 sources." should be shown
-
- ## Spellcheck
-
- @qtwebkit_skip @qt>=5.8 @cannot_have_dict=af-ZA
- Scenario: Set valid but not installed language
- When I run :set spellcheck.languages ['af-ZA']
- Then the warning "Language af-ZA is not installed *" should be shown
-
- @qtwebkit_skip @qt>=5.8 @must_have_dict=en-US
- Scenario: Set valid and installed language
- When I run :set spellcheck.languages ["en-US"]
- Then the option spellcheck.languages should be set to ["en-US"]
diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature
index ae3f07999..1374b4e10 100644
--- a/tests/end2end/features/search.feature
+++ b/tests/end2end/features/search.feature
@@ -52,6 +52,12 @@ Feature: Searching on a page
And I wait for "search didn't find blub" in the log
Then the warning "Text 'blub' not found on page!" should be shown
+ Scenario: Searching text duplicates
+ When I run :search foo
+ And I wait for "search found foo" in the log
+ And I run :search foo
+ Then "Ignoring duplicate search request for foo" should be logged
+
## search.ignore_case
Scenario: Searching text with search.ignore_case = always
@@ -225,15 +231,11 @@ Feature: Searching on a page
Then the following tabs should be open:
- data/search.html (active)
- # Following a link selected via JS doesn't work in Qt 5.10 anymore.
- @qt!=5.10
Scenario: Follow a manually selected link
When I run :jseval --file (testdata)/search_select.js
And I run :follow-selected
Then data/hello.txt should be loaded
- # Following a link selected via JS doesn't work in Qt 5.10 anymore.
- @qt!=5.10
Scenario: Follow a manually selected link in a new tab
When I run :window-only
And I run :jseval --file (testdata)/search_select.js
@@ -250,7 +252,7 @@ Feature: Searching on a page
And I run :search follow
And I wait for "search found follow" in the log
And I run :follow-selected
- Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, is_main_frame False" should be logged
+ Then "navigation request: url http://localhost:*/data/hello.txt, type Type.link_clicked, is_main_frame False" should be logged
@qtwebkit_skip: Not supported in qtwebkit
Scenario: Follow a tabbed searched link in an iframe
diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature
index 065b92096..7a36b60cb 100644
--- a/tests/end2end/features/tabs.feature
+++ b/tests/end2end/features/tabs.feature
@@ -632,12 +632,6 @@ Feature: Tab management
# https://github.com/qutebrowser/qutebrowser/issues/2289
- @qtwebkit_skip @qt==5.8.0
- Scenario: Cloning a tab with a special URL
- When I open chrome://gpu
- And I run :tab-clone
- Then the error "Can't serialize special URL!" should be shown
-
@qtwebkit_skip @qt<5.9
Scenario: Cloning a tab with a view-source URL
When I open /
@@ -904,7 +898,7 @@ Feature: Tab management
Scenario: :buffer without args or count
When I run :buffer
- Then the error "buffer: Either a count or the argument index must be specified." should be shown
+ Then qute://tabs should be loaded
Scenario: :buffer with a matching title
When I open data/title.html
diff --git a/tests/end2end/fixtures/test_webserver.py b/tests/end2end/fixtures/test_webserver.py
index a59d425e2..8187e32cd 100644
--- a/tests/end2end/fixtures/test_webserver.py
+++ b/tests/end2end/fixtures/test_webserver.py
@@ -22,6 +22,7 @@
import json
import urllib.request
import urllib.error
+from http import HTTPStatus
import pytest
@@ -52,11 +53,38 @@ def test_server(server, qtbot, path, content, expected):
@pytest.mark.parametrize('line, verb, path, equal', [
- ({'verb': 'GET', 'path': '/', 'status': 200}, 'GET', '/', True),
- ({'verb': 'GET', 'path': '/foo/', 'status': 200}, 'GET', '/foo', True),
+ ({'verb': 'GET', 'path': '/', 'status': HTTPStatus.OK}, 'GET', '/', True),
+ ({'verb': 'GET', 'path': '/foo/', 'status': HTTPStatus.OK},
+ 'GET', '/foo', True),
+ ({'verb': 'GET', 'path': '/relative-redirect', 'status': HTTPStatus.FOUND},
+ 'GET', '/relative-redirect', True),
+ ({'verb': 'GET', 'path': '/absolute-redirect', 'status': HTTPStatus.FOUND},
+ 'GET', '/absolute-redirect', True),
+ ({'verb': 'GET', 'path': '/redirect-to', 'status': HTTPStatus.FOUND},
+ 'GET', '/redirect-to', True),
+ ({'verb': 'GET', 'path': '/redirect-self', 'status': HTTPStatus.FOUND},
+ 'GET', '/redirect-self', True),
+ ({'verb': 'GET', 'path': '/content-size', 'status': HTTPStatus.OK},
+ 'GET', '/content-size', True),
+ ({'verb': 'GET', 'path': '/twenty-mb', 'status': HTTPStatus.OK},
+ 'GET', '/twenty-mb', True),
+ ({'verb': 'GET', 'path': '/500-inline',
+ 'status': HTTPStatus.INTERNAL_SERVER_ERROR}, 'GET', '/500-inline', True),
+ ({'verb': 'GET', 'path': '/basic-auth/user1/password1',
+ 'status': HTTPStatus.UNAUTHORIZED},
+ 'GET', '/basic-auth/user1/password1', True),
+ ({'verb': 'GET', 'path': '/drip', 'status': HTTPStatus.OK},
+ 'GET', '/drip', True),
+ ({'verb': 'GET', 'path': '/404', 'status': HTTPStatus.NOT_FOUND},
+ 'GET', '/404', True),
- ({'verb': 'GET', 'path': '/', 'status': 200}, 'GET', '/foo', False),
- ({'verb': 'POST', 'path': '/', 'status': 200}, 'GET', '/', False),
+ ({'verb': 'GET', 'path': '/', 'status': HTTPStatus.OK},
+ 'GET', '/foo', False),
+ ({'verb': 'POST', 'path': '/', 'status': HTTPStatus.OK},
+ 'GET', '/', False),
+ ({'verb': 'GET', 'path': '/basic-auth/user/password',
+ 'status': HTTPStatus.UNAUTHORIZED},
+ 'GET', '/basic-auth/user/passwd', False),
])
def test_expected_request(server, line, verb, path, equal):
expected = server.ExpectedRequest(verb, path)
diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py
index 2beb6fb95..254b5ffaf 100644
--- a/tests/end2end/fixtures/webserver.py
+++ b/tests/end2end/fixtures/webserver.py
@@ -23,7 +23,7 @@ import re
import sys
import json
import os.path
-import http.client
+from http import HTTPStatus
import attr
import pytest
@@ -62,32 +62,30 @@ class Request(testprocess.Line):
def _check_status(self):
"""Check if the http status is what we expected."""
- # WORKAROUND for https://github.com/PyCQA/pylint/issues/399 (?)
- # pylint: disable=no-member
path_to_statuses = {
- '/favicon.ico': [http.client.NOT_FOUND],
- '/does-not-exist': [http.client.NOT_FOUND],
- '/does-not-exist-2': [http.client.NOT_FOUND],
- '/404': [http.client.NOT_FOUND],
+ '/favicon.ico': [HTTPStatus.NOT_FOUND],
+ '/does-not-exist': [HTTPStatus.NOT_FOUND],
+ '/does-not-exist-2': [HTTPStatus.NOT_FOUND],
+ '/404': [HTTPStatus.NOT_FOUND],
- '/redirect-later': [http.client.FOUND],
- '/redirect-self': [http.client.FOUND],
- '/redirect-to': [http.client.FOUND],
- '/relative-redirect': [http.client.FOUND],
- '/absolute-redirect': [http.client.FOUND],
+ '/redirect-later': [HTTPStatus.FOUND],
+ '/redirect-self': [HTTPStatus.FOUND],
+ '/redirect-to': [HTTPStatus.FOUND],
+ '/relative-redirect': [HTTPStatus.FOUND],
+ '/absolute-redirect': [HTTPStatus.FOUND],
- '/cookies/set': [http.client.FOUND],
+ '/cookies/set': [HTTPStatus.FOUND],
- '/500-inline': [http.client.INTERNAL_SERVER_ERROR],
+ '/500-inline': [HTTPStatus.INTERNAL_SERVER_ERROR],
+ '/500': [HTTPStatus.INTERNAL_SERVER_ERROR],
}
for i in range(15):
- path_to_statuses['/redirect/{}'.format(i)] = [http.client.FOUND]
+ path_to_statuses['/redirect/{}'.format(i)] = [HTTPStatus.FOUND]
for suffix in ['', '1', '2', '3', '4', '5', '6']:
key = '/basic-auth/user{}/password{}'.format(suffix, suffix)
- path_to_statuses[key] = [http.client.UNAUTHORIZED, http.client.OK]
+ path_to_statuses[key] = [HTTPStatus.UNAUTHORIZED, HTTPStatus.OK]
- default_statuses = [http.client.OK, http.client.NOT_MODIFIED]
- # pylint: enable=no-member
+ default_statuses = [HTTPStatus.OK, HTTPStatus.NOT_MODIFIED]
sanitized = QUrl('http://localhost' + self.path).path() # Remove ?foo
expected_statuses = path_to_statuses.get(sanitized, default_statuses)
diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py
index 4ec4619f7..7d9af2ee3 100644
--- a/tests/end2end/fixtures/webserver_sub.py
+++ b/tests/end2end/fixtures/webserver_sub.py
@@ -32,6 +32,7 @@ import time
import signal
import os
import threading
+from http import HTTPStatus
import cheroot.wsgi
import flask
@@ -48,6 +49,7 @@ def root():
@app.route('/data/<path:path>')
+@app.route('/data2/<path:path>') # for per-URL settings
def send_data(path):
"""Send a given data file to qutebrowser.
@@ -112,7 +114,7 @@ def redirect_n_times(n):
def relative_redirect():
"""302 Redirect once."""
response = app.make_response('')
- response.status_code = 302
+ response.status_code = HTTPStatus.FOUND
response.headers['Location'] = flask.url_for('root')
return response
@@ -121,7 +123,7 @@ def relative_redirect():
def absolute_redirect():
"""302 Redirect once."""
response = app.make_response('')
- response.status_code = 302
+ response.status_code = HTTPStatus.FOUND
response.headers['Location'] = flask.url_for('root', _external=True)
return response
@@ -133,7 +135,7 @@ def redirect_to():
# werkzeug from "fixing" the URL. This endpoint should set the Location
# header to the exact string supplied.
response = app.make_response('')
- response.status_code = 302
+ response.status_code = HTTPStatus.FOUND
response.headers['Location'] = flask.request.args['url'].encode('utf-8')
return response
@@ -149,7 +151,7 @@ def content_size():
response = flask.Response(generate_bytes(), headers={
"Content-Type": "application/octet-stream",
})
- response.status_code = 200
+ response.status_code = HTTPStatus.OK
return response
@@ -163,7 +165,7 @@ def twenty_mb():
"Content-Type": "application/octet-stream",
"Content-Length": str(20 * 1024 * 1024),
})
- response.status_code = 200
+ response.status_code = HTTPStatus.OK
return response
@@ -174,10 +176,18 @@ def internal_error_attachment():
"Content-Type": "application/octet-stream",
"Content-Disposition": 'inline; filename="attachment.jpg"',
})
- response.status_code = 500
+ response.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
return response
+@app.route('/500')
+def internal_error():
+ """A normal 500 error."""
+ r = flask.make_response()
+ r.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
+ return r
+
+
@app.route('/cookies')
def view_cookies():
"""Show cookies."""
@@ -199,7 +209,7 @@ def basic_auth(user='user', passwd='passwd'):
auth = flask.request.authorization
if not auth or auth.username != user or auth.password != passwd:
r = flask.make_response()
- r.status_code = 401
+ r.status_code = HTTPStatus.UNAUTHORIZED
r.headers = {'WWW-Authenticate': 'Basic realm="Fake Realm"'}
return r
@@ -222,14 +232,14 @@ def drip():
"Content-Type": "application/octet-stream",
"Content-Length": str(numbytes),
})
- response.status_code = 200
+ response.status_code = HTTPStatus.OK
return response
@app.route('/404')
def status_404():
r = flask.make_response()
- r.status_code = 404
+ r.status_code = HTTPStatus.NOT_FOUND
return r
diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py
index 3b6d51e26..d6b4b1300 100644
--- a/tests/end2end/test_invocations.py
+++ b/tests/end2end/test_invocations.py
@@ -379,6 +379,7 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new):
@pytest.mark.no_xvfb
@pytest.mark.no_ci
+@pytest.mark.not_mac
def test_force_software_rendering(request, quteproc_new):
"""Make sure we can force software rendering with -s."""
if not request.config.webengine:
diff --git a/tests/end2end/test_mhtml_e2e.py b/tests/end2end/test_mhtml_e2e.py
index 78cc9eb81..feddc963d 100644
--- a/tests/end2end/test_mhtml_e2e.py
+++ b/tests/end2end/test_mhtml_e2e.py
@@ -26,6 +26,8 @@ import collections
import pytest
+from qutebrowser.utils import qtutils
+
def collect_tests():
basedir = os.path.dirname(__file__)
@@ -51,6 +53,11 @@ def normalize_line(line):
line = line.replace('Content-Type: application/x-javascript',
'Content-Type: application/javascript')
+ # Added with Qt 5.11
+ if (line.startswith('Snapshot-Content-Location: ') and
+ not qtutils.version_check('5.11', compiled=False)):
+ line = None
+
return line
@@ -74,7 +81,8 @@ class DownloadDir:
def compare_mhtml(self, filename):
with open(filename, 'r', encoding='utf-8') as f:
- expected_data = [normalize_line(line) for line in f]
+ expected_data = [normalize_line(line) for line in f
+ if normalize_line(line) is not None]
actual_data = self.read_file()
actual_data = [normalize_line(line) for line in actual_data]
assert actual_data == expected_data
diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py
index 686db4125..f8729d3f8 100644
--- a/tests/helpers/fixtures.py
+++ b/tests/helpers/fixtures.py
@@ -35,14 +35,14 @@ import types
import attr
import pytest
import py.path # pylint: disable=no-name-in-module
-from PyQt5.QtCore import QEvent, QSize, Qt
-from PyQt5.QtGui import QKeyEvent
+from PyQt5.QtCore import QSize, Qt
from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout
from PyQt5.QtNetwork import QNetworkCookieJar
import helpers.stubs as stubsmod
import helpers.utils
-from qutebrowser.config import config, configdata, configtypes, configexc
+from qutebrowser.config import (config, configdata, configtypes, configexc,
+ configfiles)
from qutebrowser.utils import objreg, standarddir
from qutebrowser.browser.webkit import cookies
from qutebrowser.misc import savemanager, sql
@@ -193,11 +193,15 @@ def configdata_init():
@pytest.fixture
-def config_stub(stubs, monkeypatch, configdata_init):
- """Fixture which provides a fake config object."""
- yaml_config = stubs.FakeYamlConfig()
+def yaml_config_stub(config_tmpdir):
+ """Fixture which provides a YamlConfig object."""
+ return configfiles.YamlConfig()
+
- conf = config.Config(yaml_config=yaml_config)
+@pytest.fixture
+def config_stub(stubs, monkeypatch, configdata_init, yaml_config_stub):
+ """Fixture which provides a fake config object."""
+ conf = config.Config(yaml_config=yaml_config_stub)
monkeypatch.setattr(config, 'instance', conf)
container = config.ConfigContainer(conf)
@@ -354,21 +358,6 @@ def webframe(webpage):
@pytest.fixture
-def fake_keyevent_factory():
- """Fixture that when called will return a mock instance of a QKeyEvent."""
- def fake_keyevent(key, modifiers=0, text='', typ=QEvent.KeyPress):
- """Generate a new fake QKeyPressEvent."""
- evtmock = unittest.mock.create_autospec(QKeyEvent, instance=True)
- evtmock.key.return_value = key
- evtmock.modifiers.return_value = modifiers
- evtmock.text.return_value = text
- evtmock.type.return_value = typ
- return evtmock
-
- return fake_keyevent
-
-
-@pytest.fixture
def cookiejar_and_cache(stubs):
"""Fixture providing a fake cookie jar and cache."""
jar = QNetworkCookieJar()
@@ -522,3 +511,12 @@ class ModelValidator:
@pytest.fixture
def model_validator(qtmodeltester):
return ModelValidator(qtmodeltester)
+
+
+@pytest.fixture
+def download_stub(win_registry, tmpdir, stubs):
+ """Register a FakeDownloadManager."""
+ stub = stubs.FakeDownloadManager(tmpdir)
+ objreg.register('qtnetwork-download-manager', stub)
+ yield stub
+ objreg.delete('qtnetwork-download-manager')
diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py
index 64bc793cb..b5e30bb0b 100644
--- a/tests/helpers/stubs.py
+++ b/tests/helpers/stubs.py
@@ -22,6 +22,8 @@
"""Fake objects/stubs."""
from unittest import mock
+import contextlib
+import shutil
import attr
from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject, QUrl
@@ -29,7 +31,7 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache,
QNetworkCacheMetaData)
from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar
-from qutebrowser.browser import browsertab
+from qutebrowser.browser import browsertab, downloads
from qutebrowser.utils import usertypes
from qutebrowser.mainwindow import mainwindow
@@ -406,33 +408,6 @@ class InstaTimer(QObject):
fun()
-class FakeYamlConfig:
-
- """Fake configfiles.YamlConfig object."""
-
- def __init__(self):
- self.loaded = False
- self._values = {}
-
- def __contains__(self, item):
- return item in self._values
-
- def __iter__(self):
- return iter(self._values.items())
-
- def __setitem__(self, key, value):
- self._values[key] = value
-
- def __getitem__(self, key):
- return self._values[key]
-
- def unset(self, name):
- self._values.pop(name, None)
-
- def clear(self):
- self._values = []
-
-
class StatusBarCommandStub(QLineEdit):
"""Stub for the statusbar command prompt."""
@@ -502,32 +477,47 @@ class TabbedBrowserStub(QObject):
"""Stub for the tabbed-browser object."""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.widget = TabWidgetStub()
+ self.shutting_down = False
+ self.opened_url = None
+
+ def on_tab_close_requested(self, idx):
+ del self.widget.tabs[idx]
+
+ def widgets(self):
+ return self.widget.tabs
+
+ def tabopen(self, url):
+ self.opened_url = url
+
+ def openurl(self, url, *, newtab):
+ self.opened_url = url
+
+
+class TabWidgetStub(QObject):
+
+ """Stub for the tab-widget object."""
+
new_tab = pyqtSignal(browsertab.AbstractTab, int)
def __init__(self, parent=None):
super().__init__(parent)
self.tabs = []
- self.shutting_down = False
self._qtabbar = QTabBar()
self.index_of = None
self.current_index = None
- self.opened_url = None
def count(self):
return len(self.tabs)
- def widgets(self):
- return self.tabs
-
def widget(self, i):
return self.tabs[i]
def page_title(self, i):
return self.tabs[i].title()
- def on_tab_close_requested(self, idx):
- del self.tabs[idx]
-
def tabBar(self):
return self._qtabbar
@@ -551,15 +541,77 @@ class TabbedBrowserStub(QObject):
return None
return self.tabs[idx - 1]
- def tabopen(self, url):
- self.opened_url = url
-
- def openurl(self, url, *, newtab):
- self.opened_url = url
-
class ApplicationStub(QObject):
"""Stub to insert as the app object in objreg."""
new_window = pyqtSignal(mainwindow.MainWindow)
+
+
+class HTTPPostStub(QObject):
+
+ """A stub class for HTTPClient.
+
+ Attributes:
+ url: the last url send by post()
+ data: the last data send by post()
+ """
+
+ success = pyqtSignal(str)
+ error = pyqtSignal(str)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.url = None
+ self.data = None
+
+ def post(self, url, data=None):
+ self.url = url
+ self.data = data
+
+
+class FakeDownloadItem(QObject):
+
+ """Mock browser.downloads.DownloadItem."""
+
+ finished = pyqtSignal()
+
+ def __init__(self, fileobj, name, parent=None):
+ super().__init__(parent)
+ self.fileobj = fileobj
+ self.name = name
+ self.successful = False
+
+
+class FakeDownloadManager:
+
+ """Mock browser.downloads.DownloadManager."""
+
+ def __init__(self, tmpdir):
+ self._tmpdir = tmpdir
+ self.downloads = []
+
+ @contextlib.contextmanager
+ def _open_fileobj(self, target):
+ """Ensure a DownloadTarget's fileobj attribute is available."""
+ if isinstance(target, downloads.FileDownloadTarget):
+ target.fileobj = open(target.filename, 'wb')
+ try:
+ yield target.fileobj
+ finally:
+ target.fileobj.close()
+ else:
+ yield target.fileobj
+
+ def get(self, url, target, **kwargs):
+ """Return a FakeDownloadItem instance with a fileobj.
+
+ The content is copied from the file the given url links to.
+ """
+ with self._open_fileobj(target):
+ download_item = FakeDownloadItem(target.fileobj, name=url.path())
+ with (self._tmpdir / url.path()).open('rb') as fake_url_file:
+ shutil.copyfileobj(fake_url_file, download_item.fileobj)
+ self.downloads.append(download_item)
+ return download_item
diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/browser/test_adblock.py
index 09161e806..5b353efb9 100644
--- a/tests/unit/browser/test_adblock.py
+++ b/tests/unit/browser/test_adblock.py
@@ -21,15 +21,13 @@
import os
import os.path
import zipfile
-import shutil
import logging
import pytest
-from PyQt5.QtCore import pyqtSignal, QUrl, QObject
+from PyQt5.QtCore import QUrl
from qutebrowser.browser import adblock
-from qutebrowser.utils import objreg
pytestmark = pytest.mark.usefixtures('qapp', 'config_tmpdir')
@@ -69,46 +67,6 @@ def basedir(fake_args):
fake_args.basedir = None
-class FakeDownloadItem(QObject):
-
- """Mock browser.downloads.DownloadItem."""
-
- finished = pyqtSignal()
-
- def __init__(self, fileobj, name, parent=None):
- super().__init__(parent)
- self.fileobj = fileobj
- self.name = name
- self.successful = True
-
-
-class FakeDownloadManager:
-
- """Mock browser.downloads.DownloadManager."""
-
- def __init__(self, tmpdir):
- self._tmpdir = tmpdir
-
- def get(self, url, target, **kwargs):
- """Return a FakeDownloadItem instance with a fileobj.
-
- The content is copied from the file the given url links to.
- """
- download_item = FakeDownloadItem(target.fileobj, name=url.path())
- with (self._tmpdir / url.path()).open('rb') as fake_url_file:
- shutil.copyfileobj(fake_url_file, download_item.fileobj)
- return download_item
-
-
-@pytest.fixture
-def download_stub(win_registry, tmpdir):
- """Register a FakeDownloadManager."""
- stub = FakeDownloadManager(tmpdir)
- objreg.register('qtnetwork-download-manager', stub)
- yield
- objreg.delete('qtnetwork-download-manager')
-
-
def create_zipfile(directory, files, zipname='test'):
"""Return a path to a newly created zip file.
@@ -248,6 +206,7 @@ def test_disabled_blocking_update(basedir, config_stub, download_stub,
while host_blocker._in_progress:
current_download = host_blocker._in_progress[0]
with caplog.at_level(logging.ERROR):
+ current_download.successful = True
current_download.finished.emit()
host_blocker.read_hosts()
for str_url in URLS_TO_CHECK:
@@ -263,6 +222,8 @@ def test_no_blocklist_update(config_stub, download_stub,
host_blocker = adblock.HostBlocker()
host_blocker.adblock_update()
host_blocker.read_hosts()
+ for dl in download_stub.downloads:
+ dl.successful = True
for str_url in URLS_TO_CHECK:
assert not host_blocker.is_blocked(QUrl(str_url))
@@ -280,6 +241,7 @@ def test_successful_update(config_stub, basedir, download_stub,
while host_blocker._in_progress:
current_download = host_blocker._in_progress[0]
with caplog.at_level(logging.ERROR):
+ current_download.successful = True
current_download.finished.emit()
host_blocker.read_hosts()
assert_urls(host_blocker, whitelisted=[])
@@ -307,6 +269,8 @@ def test_failed_dl_update(config_stub, basedir, download_stub,
# if current download is the file we want to fail, make it fail
if current_download.name == dl_fail_blocklist.path():
current_download.successful = False
+ else:
+ current_download.successful = True
with caplog.at_level(logging.ERROR):
current_download.finished.emit()
host_blocker.read_hosts()
@@ -336,16 +300,18 @@ def test_invalid_utf8(config_stub, download_stub, tmpdir, data_tmpdir,
host_blocker = adblock.HostBlocker()
host_blocker.adblock_update()
- finished_signal = host_blocker._in_progress[0].finished
+ current_download = host_blocker._in_progress[0]
if location == 'content':
with caplog.at_level(logging.ERROR):
- finished_signal.emit()
+ current_download.successful = True
+ current_download.finished.emit()
expected = (r"Failed to decode: "
r"b'https://www.example.org/\xa0localhost")
assert caplog.records[-2].message.startswith(expected)
else:
- finished_signal.emit()
+ current_download.successful = True
+ current_download.finished.emit()
host_blocker.read_hosts()
assert_urls(host_blocker, whitelisted=[])
diff --git a/tests/unit/browser/test_signalfilter.py b/tests/unit/browser/test_signalfilter.py
index 18f52a32a..957b85943 100644
--- a/tests/unit/browser/test_signalfilter.py
+++ b/tests/unit/browser/test_signalfilter.py
@@ -68,8 +68,8 @@ def objects():
@pytest.mark.parametrize('index_of, emitted', [(0, True), (1, False)])
def test_filtering(objects, tabbed_browser_stubs, index_of, emitted):
browser = tabbed_browser_stubs[0]
- browser.current_index = 0
- browser.index_of = index_of
+ browser.widget.current_index = 0
+ browser.widget.index_of = index_of
objects.signaller.signal.emit('foo')
if emitted:
assert objects.signaller.filtered_signal_arg == 'foo'
@@ -80,8 +80,8 @@ def test_filtering(objects, tabbed_browser_stubs, index_of, emitted):
@pytest.mark.parametrize('index_of, verb', [(0, 'emitting'), (1, 'ignoring')])
def test_logging(caplog, objects, tabbed_browser_stubs, index_of, verb):
browser = tabbed_browser_stubs[0]
- browser.current_index = 0
- browser.index_of = index_of
+ browser.widget.current_index = 0
+ browser.widget.index_of = index_of
with caplog.at_level(logging.DEBUG, logger='signals'):
objects.signaller.signal.emit('foo')
@@ -94,8 +94,8 @@ def test_logging(caplog, objects, tabbed_browser_stubs, index_of, verb):
@pytest.mark.parametrize('index_of', [0, 1])
def test_no_logging(caplog, objects, tabbed_browser_stubs, index_of):
browser = tabbed_browser_stubs[0]
- browser.current_index = 0
- browser.index_of = index_of
+ browser.widget.current_index = 0
+ browser.widget.index_of = index_of
with caplog.at_level(logging.DEBUG, logger='signals'):
objects.signaller.link_hovered.emit('foo')
@@ -106,7 +106,7 @@ def test_no_logging(caplog, objects, tabbed_browser_stubs, index_of):
def test_runtime_error(objects, tabbed_browser_stubs):
"""Test that there's no crash if indexOf() raises RuntimeError."""
browser = tabbed_browser_stubs[0]
- browser.current_index = 0
- browser.index_of = RuntimeError
+ browser.widget.current_index = 0
+ browser.widget.index_of = RuntimeError
objects.signaller.signal.emit('foo')
assert objects.signaller.filtered_signal_arg is None
diff --git a/tests/unit/browser/urlmarks.py b/tests/unit/browser/urlmarks.py
new file mode 100644
index 000000000..df7b3286d
--- /dev/null
+++ b/tests/unit/browser/urlmarks.py
@@ -0,0 +1,126 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""Tests for the global page history."""
+
+import pytest
+from PyQt5.QtCore import QUrl
+
+from qutebrowser.browser import urlmarks
+
+
+@pytest.fixture
+def bm_file(config_tmpdir):
+ bm_dir = config_tmpdir / 'bookmarks'
+ bm_dir.mkdir()
+ bm_file = bm_dir / 'urls'
+ return bm_file
+
+
+def test_init(bm_file, fake_save_manager):
+ bm_file.write('\n'.join([
+ 'http://example.com Example Site',
+ 'http://example.com/foo Foo',
+ 'http://example.com/bar Bar',
+ 'http://example.com/notitle',
+ ]))
+
+ bm = urlmarks.BookmarkManager()
+ fake_save_manager.add_saveable.assert_called_once_with(
+ 'bookmark-manager',
+ bm.save,
+ bm.changed,
+ filename=str(bm_file),
+ )
+
+ assert list(bm.marks.items()) == [
+ ('http://example.com', 'Example Site'),
+ ('http://example.com/foo', 'Foo'),
+ ('http://example.com/bar', 'Bar'),
+ ('http://example.com/notitle', ''),
+ ]
+
+
+def test_add(bm_file, fake_save_manager, qtbot):
+ bm = urlmarks.BookmarkManager()
+
+ with qtbot.wait_signal(bm.changed):
+ bm.add(QUrl('http://example.com'), 'Example Site')
+ assert list(bm.marks.items()) == [
+ ('http://example.com', 'Example Site'),
+ ]
+
+ with qtbot.wait_signal(bm.changed):
+ bm.add(QUrl('http://example.com/notitle'), '')
+ assert list(bm.marks.items()) == [
+ ('http://example.com', 'Example Site'),
+ ('http://example.com/notitle', ''),
+ ]
+
+
+def test_add_toggle(bm_file, fake_save_manager, qtbot):
+ bm = urlmarks.BookmarkManager()
+
+ with qtbot.wait_signal(bm.changed):
+ bm.add(QUrl('http://example.com'), '', toggle=True)
+ assert 'http://example.com' in bm.marks
+
+ with qtbot.wait_signal(bm.changed):
+ bm.add(QUrl('http://example.com'), '', toggle=True)
+ assert 'http://example.com' not in bm.marks
+
+ with qtbot.wait_signal(bm.changed):
+ bm.add(QUrl('http://example.com'), '', toggle=True)
+ assert 'http://example.com' in bm.marks
+
+
+def test_add_dupe(bm_file, fake_save_manager, qtbot):
+ bm = urlmarks.BookmarkManager()
+
+ bm.add(QUrl('http://example.com'), '')
+ with pytest.raises(urlmarks.AlreadyExistsError):
+ bm.add(QUrl('http://example.com'), '')
+
+
+def test_delete(bm_file, fake_save_manager, qtbot):
+ bm = urlmarks.BookmarkManager()
+
+ bm.add(QUrl('http://example.com/foo'), 'Foo')
+ bm.add(QUrl('http://example.com/bar'), 'Bar')
+ bm.add(QUrl('http://example.com/baz'), 'Baz')
+ bm.save()
+
+ with qtbot.wait_signal(bm.changed):
+ bm.delete('http://example.com/bar')
+ assert list(bm.marks.items()) == [
+ ('http://example.com/foo', 'Foo'),
+ ('http://example.com/baz', 'Baz'),
+ ]
+
+
+def test_save(bm_file, fake_save_manager, qtbot):
+ bm = urlmarks.BookmarkManager()
+
+ bm.add(QUrl('http://example.com'), 'Example Site')
+ bm.add(QUrl('http://example.com/notitle'), '')
+ bm.save()
+ assert bm_file.read().splitlines() == [
+ 'http://example.com Example Site',
+ 'http://example.com/notitle ',
+ ]
diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py
index 995dec44a..00549f7f7 100644
--- a/tests/unit/browser/webengine/test_webenginesettings.py
+++ b/tests/unit/browser/webengine/test_webenginesettings.py
@@ -17,22 +17,61 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+import types
+import logging
+
import pytest
pytest.importorskip('PyQt5.QtWebEngineWidgets')
from qutebrowser.browser.webengine import webenginesettings
+from qutebrowser.utils import usertypes, qtutils
+from qutebrowser.misc import objects
@pytest.fixture(autouse=True)
-def init_profiles(qapp, config_stub, cache_tmpdir, data_tmpdir):
- webenginesettings._init_profiles()
+def init(qapp, config_stub, cache_tmpdir, data_tmpdir):
+ init_args = types.SimpleNamespace(enable_webengine_inspector=False)
+ webenginesettings.init(init_args)
+ config_stub.changed.disconnect(webenginesettings._update_settings)
def test_big_cache_size(config_stub):
"""Make sure a too big cache size is handled correctly."""
config_stub.val.content.cache.size = 2 ** 63 - 1
- webenginesettings._update_settings('content.cache.size')
+ profile = webenginesettings.default_profile
+
+ webenginesettings._set_http_cache_size(profile)
+
+ assert profile.httpCacheMaximumSize() == 2 ** 31 - 1
+
+
+@pytest.mark.skipif(
+ not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer")
+def test_non_existing_dict(config_stub, monkeypatch, message_mock, caplog):
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(webenginesettings.spell, 'local_filename',
+ lambda _code: None)
+ config_stub.val.spellcheck.languages = ['af-ZA']
+
+ with caplog.at_level(logging.WARNING):
+ webenginesettings._update_settings('spellcheck.languages')
+
+ msg = message_mock.getmsg(usertypes.MessageLevel.warning)
+ expected = ("Language af-ZA is not installed - see scripts/dictcli.py in "
+ "qutebrowser's sources")
+ assert msg.text == expected
+
- size = webenginesettings.default_profile.httpCacheMaximumSize()
- assert size == 2 ** 31 - 1
+@pytest.mark.skipif(
+ not qtutils.version_check('5.8'), reason="Needs Qt 5.8 or newer")
+def test_existing_dict(config_stub, monkeypatch):
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
+ monkeypatch.setattr(webenginesettings.spell, 'local_filename',
+ lambda _code: 'en-US-8-0')
+ config_stub.val.spellcheck.languages = ['en-US']
+ webenginesettings._update_settings('spellcheck.languages')
+ for profile in [webenginesettings.default_profile,
+ webenginesettings.private_profile]:
+ assert profile.isSpellCheckEnabled()
+ assert profile.spellCheckLanguages() == ['en-US-8-0']
diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py
index ecb58f3ec..51aa091b9 100644
--- a/tests/unit/completion/test_completer.py
+++ b/tests/unit/completion/test_completer.py
@@ -200,7 +200,7 @@ def test_update_completion(txt, kind, pattern, pos_args, status_command_stub,
_set_cmd_prompt(status_command_stub, txt)
completer_obj.schedule_completion_update()
if kind is None:
- assert completion_widget_stub.set_pattern.call_count == 0
+ assert not completion_widget_stub.set_pattern.called
else:
assert completion_widget_stub.set_model.call_count == 1
model = completion_widget_stub.set_model.call_args[0][0]
@@ -313,3 +313,22 @@ def test_quickcomplete_flicker(status_command_stub, completer_obj,
completer_obj.on_selection_changed('http://example.com')
completer_obj.schedule_completion_update()
assert not completion_widget_stub.set_model.called
+
+
+def test_min_chars(status_command_stub, completer_obj, completion_widget_stub,
+ config_stub, key_config_stub):
+ """Test that an update is delayed until min_chars characters are input."""
+ config_stub.val.completion.min_chars = 3
+
+ # Test #3635, where min_chars could crash the first update
+ _set_cmd_prompt(status_command_stub, ':set c|')
+ completer_obj.schedule_completion_update()
+ assert not completion_widget_stub.set_model.called
+
+ _set_cmd_prompt(status_command_stub, ':set co|')
+ completer_obj.schedule_completion_update()
+ assert not completion_widget_stub.set_model.called
+
+ _set_cmd_prompt(status_command_stub, ':set com|')
+ completer_obj.schedule_completion_update()
+ assert completion_widget_stub.set_model.call_count == 1
diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py
index d03098090..e7d7d8d28 100644
--- a/tests/unit/completion/test_histcategory.py
+++ b/tests/unit/completion/test_histcategory.py
@@ -205,3 +205,20 @@ def test_remove_rows_fetch(hist):
hist.delete('url', '298')
cat.removeRows(297, 1)
assert cat.rowCount() == 299
+
+
+@pytest.mark.parametrize('fmt, expected', [
+ ('%Y-%m-%d', '2018-02-27'),
+ ('%m/%d/%Y %H:%M', '02/27/2018 08:30'),
+ ('', ''),
+])
+def test_timestamp_fmt(fmt, expected, model_validator, config_stub, init_sql):
+ """Validate the filtering and sorting results of set_pattern."""
+ config_stub.val.completion.timestamp_format = fmt
+ hist = sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime'])
+ atime = datetime.datetime(2018, 2, 27, 8, 30)
+ hist.insert({'url': 'foo', 'title': '', 'last_atime': atime.timestamp()})
+ cat = histcategory.HistoryCategory()
+ model_validator.set_model(cat)
+ cat.set_pattern('')
+ model_validator.validate([('foo', '', expected)])
diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py
index ff9a24112..efc30dd1c 100644
--- a/tests/unit/completion/test_models.py
+++ b/tests/unit/completion/test_models.py
@@ -28,7 +28,7 @@ from PyQt5.QtCore import QUrl
from qutebrowser.completion import completer
from qutebrowser.completion.models import miscmodels, urlmodel, configmodel
from qutebrowser.config import configdata, configtypes
-from qutebrowser.utils import objreg
+from qutebrowser.utils import objreg, usertypes
from qutebrowser.browser import history
from qutebrowser.commands import cmdutils
@@ -80,9 +80,9 @@ def cmdutils_stub(monkeypatch, stubs):
@pytest.fixture()
-def configdata_stub(monkeypatch, configdata_init):
+def configdata_stub(config_stub, monkeypatch, configdata_init):
"""Patch the configdata module to provide fake data."""
- return monkeypatch.setattr(configdata, 'DATA', collections.OrderedDict([
+ monkeypatch.setattr(configdata, 'DATA', collections.OrderedDict([
('aliases', configdata.Option(
name='aliases',
description='Aliases for commands.',
@@ -91,7 +91,8 @@ def configdata_stub(monkeypatch, configdata_init):
valtype=configtypes.Command(),
),
default={'q': 'quit'},
- backends=[],
+ backends=[usertypes.Backend.QtWebKit,
+ usertypes.Backend.QtWebEngine],
raw_backends=None)),
('bindings.default', configdata.Option(
name='bindings.default',
@@ -99,31 +100,32 @@ def configdata_stub(monkeypatch, configdata_init):
typ=configtypes.Dict(
keytype=configtypes.String(),
valtype=configtypes.Dict(
- keytype=configtypes.String(),
+ keytype=configtypes.Key(),
valtype=configtypes.Command(),
),
),
default={
'normal': collections.OrderedDict([
- ('<ctrl+q>', 'quit'),
+ ('<Ctrl+q>', 'quit'),
('d', 'tab-close'),
])
},
backends=[],
- raw_backends=None)),
+ raw_backends=None,
+ no_autoconfig=True)),
('bindings.commands', configdata.Option(
name='bindings.commands',
description='Default keybindings',
typ=configtypes.Dict(
keytype=configtypes.String(),
valtype=configtypes.Dict(
- keytype=configtypes.String(),
+ keytype=configtypes.Key(),
valtype=configtypes.Command(),
),
),
default={
'normal': collections.OrderedDict([
- ('<ctrl+q>', 'quit'),
+ ('<Ctrl+q>', 'quit'),
('ZQ', 'quit'),
('I', 'invalid'),
('d', 'scroll down'),
@@ -131,7 +133,15 @@ def configdata_stub(monkeypatch, configdata_init):
},
backends=[],
raw_backends=None)),
+ ('content.javascript.enabled', configdata.Option(
+ name='content.javascript.enabled',
+ description='Enable/Disable JavaScript',
+ typ=configtypes.Bool(),
+ default=True,
+ backends=[],
+ raw_backends=None)),
]))
+ config_stub._init_values()
@pytest.fixture
@@ -214,7 +224,7 @@ def test_command_completion(qtmodeltester, cmdutils_stub, configdata_stub,
"Commands": [
('open', 'open a url', ''),
('q', "Alias for 'quit'", ''),
- ('quit', 'quit qutebrowser', 'ZQ, <ctrl+q>'),
+ ('quit', 'quit qutebrowser', 'ZQ, <Ctrl+q>'),
('tab-close', 'Close the current tab.', ''),
]
})
@@ -239,7 +249,7 @@ def test_help_completion(qtmodeltester, cmdutils_stub, key_config_stub,
_check_completions(model, {
"Commands": [
(':open', 'open a url', ''),
- (':quit', 'quit qutebrowser', 'ZQ, <ctrl+q>'),
+ (':quit', 'quit qutebrowser', 'ZQ, <Ctrl+q>'),
(':scroll', 'Scroll the current tab in the given direction.', ''),
(':tab-close', 'Close the current tab.', ''),
],
@@ -247,6 +257,7 @@ def test_help_completion(qtmodeltester, cmdutils_stub, key_config_stub,
('aliases', 'Aliases for commands.', None),
('bindings.commands', 'Default keybindings', None),
('bindings.default', 'Default keybindings', None),
+ ('content.javascript.enabled', 'Enable/Disable JavaScript', None),
]
})
@@ -528,12 +539,12 @@ def test_session_completion(qtmodeltester, session_manager_stub):
def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry,
tabbed_browser_stubs):
- tabbed_browser_stubs[0].tabs = [
+ tabbed_browser_stubs[0].widget.tabs = [
fake_web_tab(QUrl('https://github.com'), 'GitHub', 0),
fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1),
fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2),
]
- tabbed_browser_stubs[1].tabs = [
+ tabbed_browser_stubs[1].widget.tabs = [
fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0),
]
model = miscmodels.buffer()
@@ -556,12 +567,12 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry,
def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub,
win_registry, tabbed_browser_stubs):
"""Verify closing a tab by deleting it from the completion widget."""
- tabbed_browser_stubs[0].tabs = [
+ tabbed_browser_stubs[0].widget.tabs = [
fake_web_tab(QUrl('https://github.com'), 'GitHub', 0),
fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1),
fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2)
]
- tabbed_browser_stubs[1].tabs = [
+ tabbed_browser_stubs[1].widget.tabs = [
fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0),
]
model = miscmodels.buffer()
@@ -577,19 +588,19 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub,
assert model.data(idx) == '0/2'
model.delete_cur_item(idx)
- actual = [tab.url() for tab in tabbed_browser_stubs[0].tabs]
+ actual = [tab.url() for tab in tabbed_browser_stubs[0].widget.tabs]
assert actual == [QUrl('https://github.com'),
QUrl('https://duckduckgo.com')]
def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub,
win_registry, tabbed_browser_stubs, info):
- tabbed_browser_stubs[0].tabs = [
+ tabbed_browser_stubs[0].widget.tabs = [
fake_web_tab(QUrl('https://github.com'), 'GitHub', 0),
fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1),
fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2),
]
- tabbed_browser_stubs[1].tabs = [
+ tabbed_browser_stubs[1].widget.tabs = [
fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0),
]
info.win_id = 1
@@ -609,12 +620,12 @@ def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub,
def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs,
info):
- tabbed_browser_stubs[0].tabs = [
+ tabbed_browser_stubs[0].widget.tabs = [
fake_web_tab(QUrl('https://github.com'), 'GitHub', 0),
fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1),
fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2)
]
- tabbed_browser_stubs[1].tabs = [
+ tabbed_browser_stubs[1].widget.tabs = [
fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0)
]
@@ -643,14 +654,68 @@ def test_setting_option_completion(qtmodeltester, config_stub,
"Options": [
('aliases', 'Aliases for commands.', '{"q": "quit"}'),
('bindings.commands', 'Default keybindings', (
- '{"normal": {"<ctrl+q>": "quit", "ZQ": "quit", '
+ '{"normal": {"<Ctrl+q>": "quit", "ZQ": "quit", '
'"I": "invalid", "d": "scroll down"}}')),
- ('bindings.default', 'Default keybindings',
- '{"normal": {"<ctrl+q>": "quit", "d": "tab-close"}}'),
+ ('content.javascript.enabled', 'Enable/Disable JavaScript',
+ 'true'),
+ ]
+ })
+
+
+def test_setting_customized_option_completion(qtmodeltester, config_stub,
+ configdata_stub, info):
+ info.config.set_obj('aliases', {'foo': 'nop'})
+
+ model = configmodel.customized_option(info=info)
+ model.set_pattern('')
+ qtmodeltester.data_display_may_return_none = True
+ qtmodeltester.check(model)
+
+ _check_completions(model, {
+ "Customized options": [
+ ('aliases', 'Aliases for commands.', '{"foo": "nop"}'),
]
})
+def test_setting_value_completion(qtmodeltester, config_stub, configdata_stub,
+ info):
+ model = configmodel.value(optname='content.javascript.enabled', info=info)
+ model.set_pattern('')
+ qtmodeltester.data_display_may_return_none = True
+ qtmodeltester.check(model)
+
+ _check_completions(model, {
+ "Current/Default": [
+ ('true', 'Current value', None),
+ ('true', 'Default value', None),
+ ],
+ "Completions": [
+ ('false', '', None),
+ ('true', '', None),
+ ],
+ })
+
+
+def test_setting_value_no_completions(qtmodeltester, config_stub,
+ configdata_stub, info):
+ model = configmodel.value(optname='aliases', info=info)
+ model.set_pattern('')
+ qtmodeltester.data_display_may_return_none = True
+ qtmodeltester.check(model)
+
+ _check_completions(model, {
+ "Current/Default": [
+ ('{"q": "quit"}', 'Current value', None),
+ ('{"q": "quit"}', 'Default value', None),
+ ],
+ })
+
+
+def test_setting_value_completion_invalid(info):
+ assert configmodel.value(optname='foobarbaz', info=info) is None
+
+
def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub,
key_config_stub, configdata_stub, info):
"""Test the results of keybinding command completion.
@@ -673,7 +738,7 @@ def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub,
"Commands": [
('open', 'open a url', ''),
('q', "Alias for 'quit'", ''),
- ('quit', 'quit qutebrowser', 'ZQ, <ctrl+q>'),
+ ('quit', 'quit qutebrowser', 'ZQ, <Ctrl+q>'),
('scroll', 'Scroll the current tab in the given direction.', ''),
('tab-close', 'Close the current tab.', ''),
],
@@ -693,7 +758,28 @@ def test_bind_completion_invalid(cmdutils_stub, config_stub, key_config_stub,
"Commands": [
('open', 'open a url', ''),
('q', "Alias for 'quit'", ''),
- ('quit', 'quit qutebrowser', 'ZQ, <ctrl+q>'),
+ ('quit', 'quit qutebrowser', 'ZQ, <Ctrl+q>'),
+ ('scroll', 'Scroll the current tab in the given direction.', ''),
+ ('tab-close', 'Close the current tab.', ''),
+ ],
+ })
+
+
+def test_bind_completion_invalid_binding(cmdutils_stub, config_stub,
+ key_config_stub, configdata_stub,
+ info):
+ """Test command completion with an invalid key binding."""
+ model = configmodel.bind('<blub>', info=info)
+ model.set_pattern('')
+
+ _check_completions(model, {
+ "Current/Default": [
+ ('', "Could not parse '<blub>': Got invalid key!", '<blub>'),
+ ],
+ "Commands": [
+ ('open', 'open a url', ''),
+ ('q', "Alias for 'quit'", ''),
+ ('quit', 'quit qutebrowser', 'ZQ, <Ctrl+q>'),
('scroll', 'Scroll the current tab in the given direction.', ''),
('tab-close', 'Close the current tab.', ''),
],
@@ -712,7 +798,7 @@ def test_bind_completion_no_binding(qtmodeltester, cmdutils_stub, config_stub,
"Commands": [
('open', 'open a url', ''),
('q', "Alias for 'quit'", ''),
- ('quit', 'quit qutebrowser', 'ZQ, <ctrl+q>'),
+ ('quit', 'quit qutebrowser', 'ZQ, <Ctrl+q>'),
('scroll', 'Scroll the current tab in the given direction.', ''),
('tab-close', 'Close the current tab.', ''),
],
@@ -734,7 +820,7 @@ def test_bind_completion_changed(cmdutils_stub, config_stub, key_config_stub,
"Commands": [
('open', 'open a url', ''),
('q', "Alias for 'quit'", ''),
- ('quit', 'quit qutebrowser', 'ZQ, <ctrl+q>'),
+ ('quit', 'quit qutebrowser', 'ZQ, <Ctrl+q>'),
('scroll', 'Scroll the current tab in the given direction.', ''),
('tab-close', 'Close the current tab.', ''),
],
diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py
index ce6e4e461..e1ef7ef94 100644
--- a/tests/unit/config/test_config.py
+++ b/tests/unit/config/test_config.py
@@ -18,17 +18,17 @@
"""Tests for qutebrowser.config.config."""
-import copy
import types
import unittest.mock
import pytest
-from PyQt5.QtCore import QObject
+from PyQt5.QtCore import QObject, QUrl
from PyQt5.QtGui import QColor
-from qutebrowser.config import config, configdata, configexc, configfiles
-from qutebrowser.utils import usertypes
+from qutebrowser.config import config, configdata, configexc, configutils
+from qutebrowser.utils import usertypes, urlmatch
from qutebrowser.misc import objects
+from qutebrowser.keyinput import keyutils
@pytest.fixture(autouse=True)
@@ -38,6 +38,11 @@ def configdata_init():
configdata.init()
+# Alias because we need this a lot in here.
+def keyseq(s):
+ return keyutils.KeySequence.parse(s)
+
+
class TestChangeFilter:
@pytest.fixture(autouse=True)
@@ -98,64 +103,86 @@ class TestKeyConfig:
"""Get a dict with no bindings."""
return {'normal': {}}
- @pytest.mark.parametrize('key, expected', [
- ('A', 'A'),
- ('<Ctrl-X>', '<ctrl+x>'),
- ])
- def test_prepare_valid(self, key_config_stub, key, expected):
- """Make sure prepare normalizes the key."""
- assert key_config_stub._prepare(key, 'normal') == expected
-
- def test_prepare_invalid(self, key_config_stub):
- """Make sure prepare checks the mode."""
+ def test_validate_invalid_mode(self, key_config_stub):
with pytest.raises(configexc.KeybindingError):
- assert key_config_stub._prepare('x', 'abnormal')
+ assert key_config_stub._validate(keyseq('x'), 'abnormal')
+
+ def test_validate_invalid_type(self, key_config_stub):
+ with pytest.raises(AssertionError):
+ assert key_config_stub._validate('x', 'normal')
@pytest.mark.parametrize('commands, expected', [
# Unbinding default key
- ({'a': None}, {'b': 'message-info bar'}),
+ ({'a': None}, {keyseq('b'): 'message-info bar'}),
# Additional binding
({'c': 'message-info baz'},
- {'a': 'message-info foo', 'b': 'message-info bar',
- 'c': 'message-info baz'}),
+ {keyseq('a'): 'message-info foo',
+ keyseq('b'): 'message-info bar',
+ keyseq('c'): 'message-info baz'}),
# Unbinding unknown key
- ({'x': None}, {'a': 'message-info foo', 'b': 'message-info bar'}),
+ ({'x': None}, {keyseq('a'): 'message-info foo',
+ keyseq('b'): 'message-info bar'}),
])
def test_get_bindings_for_and_get_command(self, key_config_stub,
config_stub,
commands, expected):
- orig_default_bindings = {'normal': {'a': 'message-info foo',
- 'b': 'message-info bar'},
- 'insert': {},
- 'hint': {},
- 'passthrough': {},
- 'command': {},
- 'prompt': {},
- 'caret': {},
- 'register': {}}
- config_stub.val.bindings.default = copy.deepcopy(orig_default_bindings)
+ orig_default_bindings = {
+ 'normal': {'a': 'message-info foo',
+ 'b': 'message-info bar'},
+ 'insert': {},
+ 'hint': {},
+ 'passthrough': {},
+ 'command': {},
+ 'prompt': {},
+ 'caret': {},
+ 'register': {},
+ 'yesno': {}
+ }
+ expected_default_bindings = {
+ 'normal': {keyseq('a'): 'message-info foo',
+ keyseq('b'): 'message-info bar'},
+ 'insert': {},
+ 'hint': {},
+ 'passthrough': {},
+ 'command': {},
+ 'prompt': {},
+ 'caret': {},
+ 'register': {},
+ 'yesno': {}
+ }
+
+ config_stub.val.bindings.default = orig_default_bindings
config_stub.val.bindings.commands = {'normal': commands}
bindings = key_config_stub.get_bindings_for('normal')
# Make sure the code creates a copy and doesn't modify the setting
- assert config_stub.val.bindings.default == orig_default_bindings
+ assert config_stub.val.bindings.default == expected_default_bindings
assert bindings == expected
for key, command in expected.items():
assert key_config_stub.get_command(key, 'normal') == command
+ def test_get_bindings_for_empty_command(self, key_config_stub,
+ config_stub):
+ config_stub.val.bindings.commands = {'normal': {',x': ''}}
+ bindings = key_config_stub.get_bindings_for('normal')
+ assert keyseq(',x') not in bindings
+
def test_get_command_unbound(self, key_config_stub, config_stub,
no_bindings):
config_stub.val.bindings.default = no_bindings
config_stub.val.bindings.commands = no_bindings
- assert key_config_stub.get_command('foobar', 'normal') is None
+ command = key_config_stub.get_command(keyseq('foobar'),
+ 'normal')
+ assert command is None
def test_get_command_default(self, key_config_stub, config_stub):
config_stub.val.bindings.default = {
'normal': {'x': 'message-info default'}}
config_stub.val.bindings.commands = {
'normal': {'x': 'message-info custom'}}
- cmd = 'message-info default'
- assert key_config_stub.get_command('x', 'normal', default=True) == cmd
+ command = key_config_stub.get_command(keyseq('x'), 'normal',
+ default=True)
+ assert command == 'message-info default'
@pytest.mark.parametrize('bindings, expected', [
# Simple
@@ -164,9 +191,9 @@ class TestKeyConfig:
# Multiple bindings
({'a': 'message-info foo', 'b': 'message-info foo'},
{'message-info foo': ['b', 'a']}),
- # With special keys (should be listed last and normalized)
- ({'a': 'message-info foo', '<Escape>': 'message-info foo'},
- {'message-info foo': ['a', '<escape>']}),
+ # With modifier keys (should be listed last and normalized)
+ ({'a': 'message-info foo', '<ctrl-a>': 'message-info foo'},
+ {'message-info foo': ['a', '<Ctrl+a>']}),
# Chained command
({'a': 'message-info foo ;; message-info bar'},
{'message-info foo': ['a'], 'message-info bar': ['a']}),
@@ -179,11 +206,14 @@ class TestKeyConfig:
@pytest.mark.parametrize('key', ['a', '<Ctrl-X>', 'b'])
def test_bind_duplicate(self, key_config_stub, config_stub, key):
+ seq = keyseq(key)
config_stub.val.bindings.default = {'normal': {'a': 'nop',
'<Ctrl+x>': 'nop'}}
config_stub.val.bindings.commands = {'normal': {'b': 'nop'}}
- key_config_stub.bind(key, 'message-info foo', mode='normal')
- assert key_config_stub.get_command(key, 'normal') == 'message-info foo'
+ key_config_stub.bind(seq, 'message-info foo', mode='normal')
+
+ command = key_config_stub.get_command(seq, 'normal')
+ assert command == 'message-info foo'
@pytest.mark.parametrize('mode', ['normal', 'caret'])
@pytest.mark.parametrize('command', [
@@ -194,13 +224,14 @@ class TestKeyConfig:
mode, command):
config_stub.val.bindings.default = no_bindings
config_stub.val.bindings.commands = no_bindings
+ seq = keyseq('a')
with qtbot.wait_signal(config_stub.changed):
- key_config_stub.bind('a', command, mode=mode)
+ key_config_stub.bind(seq, command, mode=mode)
- assert config_stub.val.bindings.commands[mode]['a'] == command
- assert key_config_stub.get_bindings_for(mode)['a'] == command
- assert key_config_stub.get_command('a', mode) == command
+ assert config_stub.val.bindings.commands[mode][seq] == command
+ assert key_config_stub.get_bindings_for(mode)[seq] == command
+ assert key_config_stub.get_command(seq, mode) == command
def test_bind_mode_changing(self, key_config_stub, config_stub,
no_bindings):
@@ -210,7 +241,8 @@ class TestKeyConfig:
"""
config_stub.val.bindings.default = no_bindings
config_stub.val.bindings.commands = no_bindings
- key_config_stub.bind('a', 'set-cmd-text :nop ;; rl-beginning-of-line',
+ key_config_stub.bind(keyseq('a'),
+ 'set-cmd-text :nop ;; rl-beginning-of-line',
mode='normal')
def test_bind_default(self, key_config_stub, config_stub):
@@ -219,11 +251,15 @@ class TestKeyConfig:
bound_cmd = 'message-info bound'
config_stub.val.bindings.default = {'normal': {'a': default_cmd}}
config_stub.val.bindings.commands = {'normal': {'a': bound_cmd}}
- assert key_config_stub.get_command('a', mode='normal') == bound_cmd
+ seq = keyseq('a')
- key_config_stub.bind_default('a', mode='normal')
+ command = key_config_stub.get_command(seq, mode='normal')
+ assert command == bound_cmd
- assert key_config_stub.get_command('a', mode='normal') == default_cmd
+ key_config_stub.bind_default(seq, mode='normal')
+
+ command = key_config_stub.get_command(keyseq('a'), mode='normal')
+ assert command == default_cmd
def test_bind_default_unbound(self, key_config_stub, config_stub,
no_bindings):
@@ -232,42 +268,51 @@ class TestKeyConfig:
config_stub.val.bindings.commands = no_bindings
with pytest.raises(configexc.KeybindingError,
match="Can't find binding 'foobar' in normal mode"):
- key_config_stub.bind_default('foobar', mode='normal')
+ key_config_stub.bind_default(keyseq('foobar'), mode='normal')
- @pytest.mark.parametrize('key, normalized', [
- ('a', 'a'), # default bindings
- ('b', 'b'), # custom bindings
- ('<Ctrl-X>', '<ctrl+x>')
+ @pytest.mark.parametrize('key', [
+ 'a', # default bindings
+ 'b', # custom bindings
+ '<Ctrl-X>',
])
@pytest.mark.parametrize('mode', ['normal', 'caret', 'prompt'])
def test_unbind(self, key_config_stub, config_stub, qtbot,
- key, normalized, mode):
+ key, mode):
default_bindings = {
'normal': {'a': 'nop', '<ctrl+x>': 'nop'},
'caret': {'a': 'nop', '<ctrl+x>': 'nop'},
# prompt: a mode which isn't in bindings.commands yet
'prompt': {'a': 'nop', 'b': 'nop', '<ctrl+x>': 'nop'},
}
- old_default_bindings = copy.deepcopy(default_bindings)
+ expected_default_bindings = {
+ 'normal': {keyseq('a'): 'nop', keyseq('<ctrl+x>'): 'nop'},
+ 'caret': {keyseq('a'): 'nop', keyseq('<ctrl+x>'): 'nop'},
+ # prompt: a mode which isn't in bindings.commands yet
+ 'prompt': {keyseq('a'): 'nop',
+ keyseq('b'): 'nop',
+ keyseq('<ctrl+x>'): 'nop'},
+ }
+
config_stub.val.bindings.default = default_bindings
config_stub.val.bindings.commands = {
'normal': {'b': 'nop'},
'caret': {'b': 'nop'},
}
+ seq = keyseq(key)
with qtbot.wait_signal(config_stub.changed):
- key_config_stub.unbind(key, mode=mode)
+ key_config_stub.unbind(seq, mode=mode)
- assert key_config_stub.get_command(key, mode) is None
+ assert key_config_stub.get_command(seq, mode) is None
mode_bindings = config_stub.val.bindings.commands[mode]
if key == 'b' and mode != 'prompt':
# Custom binding
- assert normalized not in mode_bindings
+ assert seq not in mode_bindings
else:
default_bindings = config_stub.val.bindings.default
- assert default_bindings[mode] == old_default_bindings[mode]
- assert mode_bindings[normalized] is None
+ assert default_bindings[mode] == expected_default_bindings[mode]
+ assert mode_bindings[seq] is None
def test_unbind_unbound(self, key_config_stub, config_stub, no_bindings):
"""Try unbinding a key which is not bound."""
@@ -275,7 +320,7 @@ class TestKeyConfig:
config_stub.val.bindings.commands = no_bindings
with pytest.raises(configexc.KeybindingError,
match="Can't find binding 'foobar' in normal mode"):
- key_config_stub.unbind('foobar', mode='normal')
+ key_config_stub.unbind(keyseq('foobar'), mode='normal')
def test_unbound_twice(self, key_config_stub, config_stub, no_bindings):
"""Try unbinding an already-unbound default key.
@@ -287,25 +332,50 @@ class TestKeyConfig:
"""
config_stub.val.bindings.default = {'normal': {'a': 'nop'}}
config_stub.val.bindings.commands = no_bindings
+ seq = keyseq('a')
+
+ key_config_stub.unbind(seq)
+ assert key_config_stub.get_command(seq, mode='normal') is None
+ key_config_stub.unbind(seq)
+ assert key_config_stub.get_command(seq, mode='normal') is None
+
+ def test_unbind_old_syntax(self, yaml_config_stub, key_config_stub,
+ config_stub):
+ """Test unbinding bindings added before the keybinding refactoring.
- key_config_stub.unbind('a')
- assert key_config_stub.get_command('a', mode='normal') is None
- key_config_stub.unbind('a')
- assert key_config_stub.get_command('a', mode='normal') is None
+ We used to normalize keys differently, so we can have <ctrl+q> in the
+ config.
+
+ See https://github.com/qutebrowser/qutebrowser/issues/3699
+ """
+ bindings = {'normal': {'<ctrl+q>': 'nop'}}
+ yaml_config_stub.set_obj('bindings.commands', bindings)
+ config_stub.read_yaml()
+
+ key_config_stub.unbind(keyutils.KeySequence.parse('<ctrl+q>'),
+ save_yaml=True)
+
+ assert config.instance.get_obj('bindings.commands') == {'normal': {}}
def test_empty_command(self, key_config_stub):
"""Try binding a key to an empty command."""
message = "Can't add binding 'x' with empty command in normal mode"
with pytest.raises(configexc.KeybindingError, match=message):
- key_config_stub.bind('x', ' ', mode='normal')
+ key_config_stub.bind(keyseq('x'), ' ', mode='normal')
class TestConfig:
@pytest.fixture
- def conf(self, config_tmpdir):
- yaml_config = configfiles.YamlConfig()
- return config.Config(yaml_config)
+ def conf(self, config_stub):
+ return config_stub
+
+ @pytest.fixture
+ def yaml_value(self, conf):
+ """Fixture which provides a getter for a YAML value."""
+ def getter(option):
+ return conf._yaml._values[option].get_for_url(fallback=False)
+ return getter
def test_init_save_manager(self, conf, fake_save_manager):
conf.init_save_manager(fake_save_manager)
@@ -327,10 +397,10 @@ class TestConfig:
monkeypatch.setattr(config.objects, 'backend', objects.NoBackend())
opt = conf.get_opt('tabs.show')
conf._set_value(opt, 'never')
- assert conf._values['tabs.show'] == 'never'
+ assert conf.get_obj('tabs.show') == 'never'
@pytest.mark.parametrize('save_yaml', [True, False])
- def test_unset(self, conf, qtbot, save_yaml):
+ def test_unset(self, conf, qtbot, yaml_value, save_yaml):
name = 'tabs.show'
conf.set_obj(name, 'never', save_yaml=True)
assert conf.get(name) == 'never'
@@ -340,9 +410,9 @@ class TestConfig:
assert conf.get(name) == 'always'
if save_yaml:
- assert name not in conf._yaml
+ assert yaml_value(name) is configutils.UNSET
else:
- assert conf._yaml[name] == 'never'
+ assert yaml_value(name) == 'never'
def test_unset_never_set(self, conf, qtbot):
name = 'tabs.show'
@@ -353,18 +423,14 @@ class TestConfig:
assert conf.get(name) == 'always'
- def test_unset_unknown(self, conf):
- with pytest.raises(configexc.NoOptionError):
- conf.unset('tabs')
-
@pytest.mark.parametrize('save_yaml', [True, False])
- def test_clear(self, conf, qtbot, save_yaml):
+ def test_clear(self, conf, qtbot, yaml_value, save_yaml):
name1 = 'tabs.show'
name2 = 'content.plugins'
conf.set_obj(name1, 'never', save_yaml=True)
conf.set_obj(name2, True, save_yaml=True)
- assert conf._values[name1] == 'never'
- assert conf._values[name2] is True
+ assert conf.get_obj(name1) == 'never'
+ assert conf.get_obj(name2) is True
with qtbot.waitSignals([conf.changed, conf.changed]) as blocker:
conf.clear(save_yaml=save_yaml)
@@ -373,33 +439,52 @@ class TestConfig:
assert options == {name1, name2}
if save_yaml:
- assert name1 not in conf._yaml
- assert name2 not in conf._yaml
+ assert yaml_value(name1) is configutils.UNSET
+ assert yaml_value(name2) is configutils.UNSET
else:
- assert conf._yaml[name1] == 'never'
- assert conf._yaml[name2] is True
+ assert yaml_value(name1) == 'never'
+ assert yaml_value(name2) is True
- def test_read_yaml(self, conf):
- conf._yaml['content.plugins'] = True
+ def test_read_yaml(self, conf, yaml_value):
+ conf._yaml.set_obj('content.plugins', True)
conf.read_yaml()
- assert conf._values['content.plugins'] is True
+ assert conf.get_obj('content.plugins') is True
def test_get_opt_valid(self, conf):
assert conf.get_opt('tabs.show') == configdata.DATA['tabs.show']
- def test_get_opt_invalid(self, conf):
+ @pytest.mark.parametrize('code', [
+ lambda c: c.get_opt('tabs'),
+ lambda c: c.get('tabs'),
+ lambda c: c.get_obj('tabs'),
+ lambda c: c.get_obj_for_pattern('tabs', pattern=None),
+ lambda c: c.get_mutable_obj('tabs'),
+ lambda c: c.get_str('tabs'),
+
+ lambda c: c.set_obj('tabs', 42),
+ lambda c: c.set_str('tabs', '42'),
+ lambda c: c.unset('tabs'),
+ ])
+ def test_no_option_error(self, conf, code):
with pytest.raises(configexc.NoOptionError):
- conf.get_opt('tabs')
+ code(conf)
def test_get(self, conf):
"""Test conf.get() with a QColor (where get/get_obj is different)."""
assert conf.get('colors.completion.category.fg') == QColor('white')
+ def test_get_for_url(self, conf):
+ """Test conf.get() with an URL/pattern."""
+ pattern = urlmatch.UrlPattern('*://example.com/')
+ name = 'content.javascript.enabled'
+ conf.set_obj(name, False, pattern=pattern)
+ assert conf.get(name, url=QUrl('https://example.com/')) is False
+
@pytest.mark.parametrize('value', [{}, {'normal': {'a': 'nop'}}])
def test_get_bindings(self, config_stub, conf, value):
"""Test conf.get() with bindings which have missing keys."""
config_stub.val.aliases = {}
- conf._values['bindings.commands'] = value
+ conf.set_obj('bindings.commands', value)
assert conf.get('bindings.commands')['prompt'] == {}
def test_get_mutable(self, conf):
@@ -415,7 +500,7 @@ class TestConfig:
'bindings.commands'])
@pytest.mark.parametrize('mutable', [True, False])
@pytest.mark.parametrize('mutated', [True, False])
- def test_get_obj_mutable(self, conf, config_stub, qtbot, caplog,
+ def test_get_obj_mutable(self, conf, qtbot, caplog,
option, mutable, mutated):
"""Make sure mutables are handled correctly.
@@ -432,7 +517,7 @@ class TestConfig:
(keyhint.blacklist).
"""
# Setting new value
- obj = conf.get_obj(option, mutable=mutable)
+ obj = conf.get_mutable_obj(option) if mutable else conf.get_obj(option)
with qtbot.assert_not_emitted(conf.changed):
if option == 'content.headers.custom':
old = {}
@@ -454,7 +539,6 @@ class TestConfig:
assert obj == new
else:
assert option == 'bindings.commands'
- config_stub.val.aliases = {}
old = {}
new = {}
assert obj == old
@@ -485,9 +569,9 @@ class TestConfig:
def test_get_mutable_twice(self, conf):
"""Get a mutable value twice."""
option = 'content.headers.custom'
- obj = conf.get_obj(option, mutable=True)
+ obj = conf.get_mutable_obj(option)
obj['X-Foo'] = 'fooval'
- obj2 = conf.get_obj(option, mutable=True)
+ obj2 = conf.get_mutable_obj(option)
obj2['X-Bar'] = 'barval'
conf.update_mutables()
@@ -497,9 +581,32 @@ class TestConfig:
def test_get_obj_unknown_mutable(self, conf):
"""Make sure we don't have unknown mutable types."""
- conf._values['aliases'] = set() # This would never happen
with pytest.raises(AssertionError):
- conf.get_obj('aliases')
+ conf._maybe_copy(set())
+
+ def test_copy_non_mutable(self, conf, mocker):
+ """Make sure no copies are done for non-mutable types."""
+ spy = mocker.spy(config.copy, 'deepcopy')
+ conf.get_mutable_obj('content.plugins')
+ assert not spy.called
+
+ def test_copy_mutable(self, conf, mocker):
+ """Make sure mutable types are only copied once."""
+ spy = mocker.spy(config.copy, 'deepcopy')
+ conf.get_mutable_obj('bindings.commands')
+ spy.assert_called_once_with(mocker.ANY)
+
+ def test_get_obj_for_pattern(self, conf):
+ pattern = urlmatch.UrlPattern('*://example.com')
+ name = 'content.javascript.enabled'
+ conf.set_obj(name, False, pattern=pattern)
+ assert conf.get_obj_for_pattern(name, pattern=pattern) is False
+
+ def test_get_obj_for_pattern_no_match(self, conf):
+ pattern = urlmatch.UrlPattern('*://example.com')
+ name = 'content.javascript.enabled'
+ value = conf.get_obj_for_pattern(name, pattern=pattern)
+ assert value is configutils.UNSET
def test_get_str(self, conf):
assert conf.get_str('content.plugins') == 'false'
@@ -509,16 +616,17 @@ class TestConfig:
('set_obj', True),
('set_str', 'true'),
])
- def test_set_valid(self, conf, qtbot, save_yaml, method, value):
+ def test_set_valid(self, conf, qtbot, yaml_value,
+ save_yaml, method, value):
option = 'content.plugins'
meth = getattr(conf, method)
with qtbot.wait_signal(conf.changed):
meth(option, value, save_yaml=save_yaml)
- assert conf._values[option] is True
+ assert conf.get_obj(option) is True
if save_yaml:
- assert conf._yaml[option] is True
+ assert yaml_value(option) is True
else:
- assert option not in conf._yaml
+ assert yaml_value(option) is configutils.UNSET
@pytest.mark.parametrize('method', ['set_obj', 'set_str'])
def test_set_invalid(self, conf, qtbot, method):
@@ -526,7 +634,7 @@ class TestConfig:
with pytest.raises(configexc.ValidationError):
with qtbot.assert_not_emitted(conf.changed):
meth('content.plugins', '42')
- assert 'content.plugins' not in conf._values
+ assert not conf._values['content.plugins']
@pytest.mark.parametrize('method', ['set_obj', 'set_str'])
def test_set_wrong_backend(self, conf, qtbot, monkeypatch, method):
@@ -535,7 +643,43 @@ class TestConfig:
with pytest.raises(configexc.BackendError):
with qtbot.assert_not_emitted(conf.changed):
meth('content.cookies.accept', 'all')
- assert 'content.cookies.accept' not in conf._values
+ assert not conf._values['content.cookies.accept']
+
+ @pytest.mark.parametrize('method, value', [
+ ('set_obj', {}),
+ ('set_str', '{}'),
+ ])
+ def test_set_no_autoconfig_save(self, conf, qtbot, yaml_value,
+ method, value):
+ meth = getattr(conf, method)
+ option = 'bindings.default'
+ with pytest.raises(configexc.NoAutoconfigError):
+ with qtbot.assert_not_emitted(conf.changed):
+ meth(option, value, save_yaml=True)
+
+ assert not conf._values[option]
+ assert yaml_value(option) is configutils.UNSET
+
+ @pytest.mark.parametrize('method, value', [
+ ('set_obj', {}),
+ ('set_str', '{}'),
+ ])
+ def test_set_no_autoconfig_no_save(self, conf, qtbot, yaml_value,
+ method, value):
+ meth = getattr(conf, method)
+ option = 'bindings.default'
+ with qtbot.wait_signal(conf.changed):
+ meth(option, value)
+
+ assert conf._values[option]
+
+ @pytest.mark.parametrize('method', ['set_obj', 'set_str'])
+ def test_set_no_pattern(self, conf, method, qtbot):
+ meth = getattr(conf, method)
+ pattern = urlmatch.UrlPattern('https://www.example.com/')
+ with pytest.raises(configexc.NoPatternError):
+ with qtbot.assert_not_emitted(conf.changed):
+ meth('colors.statusbar.normal.bg', '#abcdef', pattern=pattern)
def test_dump_userconfig(self, conf):
conf.set_obj('content.plugins', True)
@@ -581,7 +725,7 @@ class TestContainer:
def test_setattr_option(self, config_stub, container):
container.content.cookies.store = False
- assert config_stub._values['content.cookies.store'] is False
+ assert config_stub.get_obj('content.cookies.store') is False
def test_confapi_errors(self, container):
configapi = types.SimpleNamespace(errors=[])
@@ -593,6 +737,12 @@ class TestContainer:
assert error.text == "While getting 'tabs.foobar'"
assert str(error.exception) == "No option 'tabs.foobar'"
+ def test_pattern_no_configapi(self, config_stub):
+ pattern = urlmatch.UrlPattern('https://example.com/')
+ with pytest.raises(TypeError,
+ match="Can't use pattern without configapi!"):
+ config.ConfigContainer(config_stub, pattern=pattern)
+
class StyleObj(QObject):
diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py
index c82195906..056bdb455 100644
--- a/tests/unit/config/test_configcommands.py
+++ b/tests/unit/config/test_configcommands.py
@@ -19,22 +19,37 @@
"""Tests for qutebrowser.config.configcommands."""
import logging
+import functools
import unittest.mock
import pytest
from PyQt5.QtCore import QUrl
-from qutebrowser.config import configcommands
+from qutebrowser.config import configcommands, configutils
from qutebrowser.commands import cmdexc
-from qutebrowser.utils import usertypes
+from qutebrowser.utils import usertypes, urlmatch
+from qutebrowser.keyinput import keyutils
from qutebrowser.misc import objects
+# Alias because we need this a lot in here.
+def keyseq(s):
+ return keyutils.KeySequence.parse(s)
+
+
@pytest.fixture
def commands(config_stub, key_config_stub):
return configcommands.ConfigCommands(config_stub, key_config_stub)
+@pytest.fixture
+def yaml_value(config_stub):
+ """Fixture which provides a getter for a YAML value."""
+ def getter(option):
+ return config_stub._yaml._values[option].get_for_url(fallback=False)
+ return getter
+
+
class TestSet:
"""Tests for :set."""
@@ -64,7 +79,7 @@ class TestSet:
['gvim', '-f', '{file}', '-c', 'normal {line}G{column0}l'],
'[emacs, "{}"]', ['emacs', '{}']),
])
- def test_set_simple(self, monkeypatch, commands, config_stub,
+ def test_set_simple(self, monkeypatch, commands, config_stub, yaml_value,
temp, option, old_value, inp, new_value):
"""Run ':set [-t] option value'.
@@ -76,14 +91,39 @@ class TestSet:
commands.set(0, option, inp, temp=temp)
assert config_stub.get(option) == new_value
+ assert yaml_value(option) == (configutils.UNSET if temp else new_value)
- if temp:
- assert option not in config_stub._yaml
- else:
- assert config_stub._yaml[option] == new_value
+ def test_set_with_pattern(self, monkeypatch, commands, config_stub):
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
+ option = 'content.javascript.enabled'
+
+ commands.set(0, option, 'false', pattern='*://example.com')
+ pattern = urlmatch.UrlPattern('*://example.com')
+
+ assert config_stub.get(option)
+ assert not config_stub.get_obj_for_pattern(option, pattern=pattern)
+
+ def test_set_invalid_pattern(self, monkeypatch, commands):
+ monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
+ option = 'content.javascript.enabled'
+
+ with pytest.raises(cmdexc.CommandError,
+ match=('Error while parsing http://: Pattern '
+ 'without host')):
+ commands.set(0, option, 'false', pattern='http://')
+
+ def test_set_no_pattern(self, monkeypatch, commands):
+ """Run ':set --pattern=*://* colors.statusbar.normal.bg #abcdef.
+
+ Should show an error as patterns are unsupported.
+ """
+ with pytest.raises(cmdexc.CommandError,
+ match='does not support URL patterns'):
+ commands.set(0, 'colors.statusbar.normal.bg', '#abcdef',
+ pattern='*://*')
@pytest.mark.parametrize('temp', [True, False])
- def test_set_temp_override(self, commands, config_stub, temp):
+ def test_set_temp_override(self, commands, config_stub, yaml_value, temp):
"""Invoking :set twice.
:set url.auto_search dns
@@ -96,19 +136,28 @@ class TestSet:
commands.set(0, 'url.auto_search', 'never', temp=True)
assert config_stub.val.url.auto_search == 'never'
- assert config_stub._yaml['url.auto_search'] == 'dns'
+ assert yaml_value('url.auto_search') == 'dns'
- def test_set_print(self, config_stub, commands, message_mock):
- """Run ':set -p url.auto_search never'.
+ @pytest.mark.parametrize('pattern', [None, '*://example.com'])
+ def test_set_print(self, config_stub, commands, message_mock, pattern):
+ """Run ':set -p [-u *://example.com] content.javascript.enabled false'.
Should set show the value.
"""
- assert config_stub.val.url.auto_search == 'naive'
- commands.set(0, 'url.auto_search', 'dns', print_=True)
+ assert config_stub.val.content.javascript.enabled
+ commands.set(0, 'content.javascript.enabled', 'false', print_=True,
+ pattern=pattern)
+
+ value = config_stub.get_obj_for_pattern(
+ 'content.javascript.enabled',
+ pattern=None if pattern is None else urlmatch.UrlPattern(pattern))
+ assert not value
- assert config_stub.val.url.auto_search == 'dns'
+ expected = 'content.javascript.enabled = false'
+ if pattern is not None:
+ expected += ' for {}'.format(pattern)
msg = message_mock.getmsg(usertypes.MessageLevel.info)
- assert msg.text == 'url.auto_search = dns'
+ assert msg.text == expected
def test_set_invalid_option(self, commands):
"""Run ':set foo bar'.
@@ -177,13 +226,14 @@ class TestCycle:
# Value which is not in the list
('red', 'green'),
])
- def test_cycling(self, commands, config_stub, initial, expected):
+ def test_cycling(self, commands, config_stub, yaml_value,
+ initial, expected):
"""Run ':set' with multiple values."""
opt = 'colors.statusbar.normal.bg'
config_stub.set_obj(opt, initial)
commands.config_cycle(opt, 'green', 'magenta', 'blue', 'yellow')
assert config_stub.get(opt) == expected
- assert config_stub._yaml[opt] == expected
+ assert yaml_value(opt) == expected
def test_different_representation(self, commands, config_stub):
"""When using a different representation, cycling should work.
@@ -205,7 +255,7 @@ class TestCycle:
assert not config_stub.val.auto_save.session
commands.config_cycle('auto_save.session')
assert config_stub.val.auto_save.session
- assert config_stub._yaml['auto_save.session']
+ assert yaml_value('auto_save.session')
@pytest.mark.parametrize('args', [
['url.auto_search'], ['url.auto_search', 'foo']
@@ -239,34 +289,28 @@ class TestUnsetAndClear:
"""Test :config-unset and :config-clear."""
@pytest.mark.parametrize('temp', [True, False])
- def test_unset(self, commands, config_stub, temp):
+ def test_unset(self, commands, config_stub, yaml_value, temp):
name = 'tabs.show'
config_stub.set_obj(name, 'never', save_yaml=True)
commands.config_unset(name, temp=temp)
assert config_stub.get(name) == 'always'
- if temp:
- assert config_stub._yaml[name] == 'never'
- else:
- assert name not in config_stub._yaml
+ assert yaml_value(name) == ('never' if temp else configutils.UNSET)
def test_unset_unknown_option(self, commands):
with pytest.raises(cmdexc.CommandError, match="No option 'tabs'"):
commands.config_unset('tabs')
@pytest.mark.parametrize('save', [True, False])
- def test_clear(self, commands, config_stub, save):
+ def test_clear(self, commands, config_stub, yaml_value, save):
name = 'tabs.show'
config_stub.set_obj(name, 'never', save_yaml=True)
commands.config_clear(save=save)
assert config_stub.get(name) == 'always'
- if save:
- assert name not in config_stub._yaml
- else:
- assert config_stub._yaml[name] == 'never'
+ assert yaml_value(name) == (configutils.UNSET if save else 'never')
class TestSource:
@@ -309,13 +353,26 @@ class TestSource:
" While setting 'foo': No option 'foo'")
assert str(excinfo.value) == expected
+ def test_invalid_source(self, commands, config_tmpdir):
+ pyfile = config_tmpdir / 'config.py'
+ pyfile.write_text('1/0', encoding='utf-8')
+
+ with pytest.raises(cmdexc.CommandError) as excinfo:
+ commands.config_source()
+
+ expected = ("Errors occurred while reading config.py:\n"
+ " Unhandled exception - ZeroDivisionError:"
+ " division by zero")
+ assert str(excinfo.value) == expected
+
class TestEdit:
"""Tests for :config-edit."""
pytestmark = pytest.mark.usefixtures('config_tmpdir', 'data_tmpdir',
- 'config_stub', 'key_config_stub')
+ 'config_stub', 'key_config_stub',
+ 'qapp')
def test_no_source(self, commands, mocker):
mock = mocker.patch('qutebrowser.config.configcommands.editor.'
@@ -367,7 +424,7 @@ class TestWritePy:
def test_custom(self, commands, config_stub, key_config_stub, tmpdir):
confpy = tmpdir / 'config.py'
config_stub.val.content.javascript.enabled = True
- key_config_stub.bind(',x', 'message-info foo', mode='normal')
+ key_config_stub.bind(keyseq(',x'), 'message-info foo', mode='normal')
commands.config_write_py(str(confpy))
@@ -441,15 +498,15 @@ class TestBind:
@pytest.mark.parametrize('command', ['nop', 'nope'])
def test_bind(self, commands, config_stub, no_bindings, key_config_stub,
- command):
+ yaml_value, command):
"""Simple :bind test (and aliases)."""
config_stub.val.aliases = {'nope': 'nop'}
config_stub.val.bindings.default = no_bindings
config_stub.val.bindings.commands = no_bindings
commands.bind(0, 'a', command)
- assert key_config_stub.get_command('a', 'normal') == command
- yaml_bindings = config_stub._yaml['bindings.commands']['normal']
+ assert key_config_stub.get_command(keyseq('a'), 'normal') == command
+ yaml_bindings = yaml_value('bindings.commands')['normal']
assert yaml_bindings['a'] == command
@pytest.mark.parametrize('key, mode, expected', [
@@ -461,7 +518,7 @@ class TestBind:
('c', 'normal', "c is bound to 'message-info c' in normal mode"),
# Special key
('<Ctrl-X>', 'normal',
- "<ctrl+x> is bound to 'message-info C-x' in normal mode"),
+ "<Ctrl+x> is bound to 'message-info C-x' in normal mode"),
# unbound
('x', 'normal', "x is unbound in normal mode"),
# non-default mode
@@ -489,23 +546,45 @@ class TestBind:
msg = message_mock.getmsg(usertypes.MessageLevel.info)
assert msg.text == expected
- def test_bind_invalid_mode(self, commands):
- """Run ':bind --mode=wrongmode a nop'.
+ @pytest.mark.parametrize('command, args, kwargs, expected', [
+ # :bind --mode=wrongmode a nop
+ ('bind', ['a', 'nop'], {'mode': 'wrongmode'},
+ 'Invalid mode wrongmode!'),
+ # :bind --mode=wrongmode a
+ ('bind', ['a'], {'mode': 'wrongmode'},
+ 'Invalid mode wrongmode!'),
+ # :bind --default --mode=wrongmode a
+ ('bind', ['a'], {'mode': 'wrongmode', 'default': True},
+ 'Invalid mode wrongmode!'),
+ # :bind --default foobar
+ ('bind', ['foobar'], {'default': True},
+ "Can't find binding 'foobar' in normal mode"),
+ # :bind <blub> nop
+ ('bind', ['<blub>', 'nop'], {},
+ "Could not parse '<blub>': Got invalid key!"),
+ # :unbind foobar
+ ('unbind', ['foobar'], {},
+ "Can't find binding 'foobar' in normal mode"),
+ # :unbind --mode=wrongmode x
+ ('unbind', ['x'], {'mode': 'wrongmode'},
+ 'Invalid mode wrongmode!'),
+ # :unbind <blub>
+ ('unbind', ['<blub>'], {},
+ "Could not parse '<blub>': Got invalid key!"),
+ ])
+ def test_bind_invalid(self, commands,
+ command, args, kwargs, expected):
+ """Run various wrong :bind/:unbind invocations.
Should show an error.
"""
- with pytest.raises(cmdexc.CommandError,
- match='Invalid mode wrongmode!'):
- commands.bind(0, 'a', 'nop', mode='wrongmode')
+ if command == 'bind':
+ func = functools.partial(commands.bind, 0)
+ elif command == 'unbind':
+ func = commands.unbind
- def test_bind_print_invalid_mode(self, commands):
- """Run ':bind --mode=wrongmode a'.
-
- Should show an error.
- """
- with pytest.raises(cmdexc.CommandError,
- match='Invalid mode wrongmode!'):
- commands.bind(0, 'a', mode='wrongmode')
+ with pytest.raises(cmdexc.CommandError, match=expected):
+ func(*args, **kwargs)
@pytest.mark.parametrize('key', ['a', 'b', '<Ctrl-X>'])
def test_bind_duplicate(self, commands, config_stub, key_config_stub, key):
@@ -521,7 +600,8 @@ class TestBind:
}
commands.bind(0, key, 'message-info foo', mode='normal')
- assert key_config_stub.get_command(key, 'normal') == 'message-info foo'
+ command = key_config_stub.get_command(keyseq(key), 'normal')
+ assert command == 'message-info foo'
def test_bind_none(self, commands, config_stub):
config_stub.val.bindings.commands = None
@@ -533,23 +613,13 @@ class TestBind:
bound_cmd = 'message-info bound'
config_stub.val.bindings.default = {'normal': {'a': default_cmd}}
config_stub.val.bindings.commands = {'normal': {'a': bound_cmd}}
- assert key_config_stub.get_command('a', mode='normal') == bound_cmd
+ command = key_config_stub.get_command(keyseq('a'), mode='normal')
+ assert command == bound_cmd
commands.bind(0, 'a', mode='normal', default=True)
- assert key_config_stub.get_command('a', mode='normal') == default_cmd
-
- @pytest.mark.parametrize('key, mode, expected', [
- ('foobar', 'normal', "Can't find binding 'foobar' in normal mode"),
- ('x', 'wrongmode', "Invalid mode wrongmode!"),
- ])
- def test_bind_default_invalid(self, commands, key, mode, expected):
- """Run ':bind --default foobar' / ':bind --default x wrongmode'.
-
- Should show an error.
- """
- with pytest.raises(cmdexc.CommandError, match=expected):
- commands.bind(0, key, mode=mode, default=True)
+ command = key_config_stub.get_command(keyseq('a'), mode='normal')
+ assert command == default_cmd
def test_unbind_none(self, commands, config_stub):
config_stub.val.bindings.commands = None
@@ -559,9 +629,9 @@ class TestBind:
('a', 'a'), # default bindings
('b', 'b'), # custom bindings
('c', 'c'), # :bind then :unbind
- ('<Ctrl-X>', '<ctrl+x>') # normalized special binding
+ ('<Ctrl-X>', '<Ctrl+x>') # normalized special binding
])
- def test_unbind(self, commands, key_config_stub, config_stub,
+ def test_unbind(self, commands, key_config_stub, config_stub, yaml_value,
key, normalized):
config_stub.val.bindings.default = {
'normal': {'a': 'nop', '<ctrl+x>': 'nop'},
@@ -576,23 +646,11 @@ class TestBind:
commands.bind(0, key, 'nop')
commands.unbind(key)
- assert key_config_stub.get_command(key, 'normal') is None
+ assert key_config_stub.get_command(keyseq(key), 'normal') is None
- yaml_bindings = config_stub._yaml['bindings.commands']['normal']
+ yaml_bindings = yaml_value('bindings.commands')['normal']
if key in 'bc':
# Custom binding
assert normalized not in yaml_bindings
else:
assert yaml_bindings[normalized] is None
-
- @pytest.mark.parametrize('key, mode, expected', [
- ('foobar', 'normal', "Can't find binding 'foobar' in normal mode"),
- ('x', 'wrongmode', "Invalid mode wrongmode!"),
- ])
- def test_unbind_invalid(self, commands, key, mode, expected):
- """Run ':unbind foobar' / ':unbind x wrongmode'.
-
- Should show an error.
- """
- with pytest.raises(cmdexc.CommandError, match=expected):
- commands.unbind(key, mode=mode)
diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py
index f415ed2fb..c11850a15 100644
--- a/tests/unit/config/test_configexc.py
+++ b/tests/unit/config/test_configexc.py
@@ -48,12 +48,24 @@ def test_no_option_error_clash():
configexc.NoOptionError('opt', deleted=True, renamed='foo')
+def test_no_autoconfig_error():
+ e = configexc.NoAutoconfigError('opt')
+ expected = "The opt setting can only be set in config.py!"
+ assert str(e) == expected
+
+
def test_backend_error():
e = configexc.BackendError('foo', usertypes.Backend.QtWebKit)
expected = "The foo setting is not available with the QtWebKit backend!"
assert str(e) == expected
+def test_no_pattern_error():
+ e = configexc.NoPatternError('foo')
+ expected = "The foo setting does not support URL patterns!"
+ assert str(e) == expected
+
+
def test_desc_with_text():
"""Test ConfigErrorDesc.with_text."""
old = configexc.ConfigErrorDesc("Error text", Exception("Exception text"))
@@ -74,7 +86,7 @@ def test_config_file_errors_str(errors):
assert str(errors).splitlines() == [
'Errors occurred while reading config.py:',
' Error text 1: Exception 1',
- ' Error text 2: Exception 2',
+ ' Error text 2 - Exception: Exception 2',
]
diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py
index 341fad689..96f5d4976 100644
--- a/tests/unit/config/test_configfiles.py
+++ b/tests/unit/config/test_configfiles.py
@@ -28,7 +28,8 @@ from PyQt5.QtCore import QSettings
from qutebrowser.config import (config, configfiles, configexc, configdata,
configtypes)
-from qutebrowser.utils import utils, usertypes
+from qutebrowser.utils import utils, usertypes, urlmatch
+from qutebrowser.keyinput import keyutils
@pytest.fixture(autouse=True)
@@ -38,6 +39,42 @@ def configdata_init():
configdata.init()
+class AutoConfigHelper:
+
+ """A helper to easily create/validate autoconfig.yml files."""
+
+ def __init__(self, config_tmpdir):
+ self.fobj = config_tmpdir / 'autoconfig.yml'
+
+ def write_toplevel(self, data):
+ with self.fobj.open('w', encoding='utf-8') as f:
+ utils.yaml_dump(data, f)
+
+ def write(self, values):
+ data = {'config_version': 2, 'settings': values}
+ self.write_toplevel(data)
+
+ def write_raw(self, text):
+ self.fobj.write_text(text, encoding='utf-8', ensure=True)
+
+ def read_toplevel(self):
+ with self.fobj.open('r', encoding='utf-8') as f:
+ data = utils.yaml_load(f)
+ assert data['config_version'] == 2
+ return data
+
+ def read(self):
+ return self.read_toplevel()['settings']
+
+ def read_raw(self):
+ return self.fobj.read_text('utf-8')
+
+
+@pytest.fixture
+def autoconfig(config_tmpdir):
+ return AutoConfigHelper(config_tmpdir)
+
+
@pytest.mark.parametrize('old_data, insert, new_data', [
(None, False, '[general]\n\n[geometry]\n\n'),
('[general]\nfooled = true', False, '[general]\n\n[geometry]\n\n'),
@@ -75,49 +112,58 @@ class TestYaml:
@pytest.mark.parametrize('old_config', [
None,
- 'global:\n colors.hints.fg: magenta',
+ # Only global
+ {'colors.hints.fg': {'global': 'magenta'}},
+ # Global and for pattern
+ {'content.javascript.enabled':
+ {'global': True, 'https://example.com/': False}},
+ # Only for pattern
+ {'content.images': {'https://example.com/': False}},
])
@pytest.mark.parametrize('insert', [True, False])
- def test_yaml_config(self, yaml, config_tmpdir, old_config, insert):
- autoconfig = config_tmpdir / 'autoconfig.yml'
+ def test_yaml_config(self, yaml, autoconfig, old_config, insert):
if old_config is not None:
- autoconfig.write_text(old_config, 'utf-8')
+ autoconfig.write(old_config)
yaml.load()
if insert:
- yaml['tabs.show'] = 'never'
+ yaml.set_obj('tabs.show', 'never')
yaml._save()
if not insert and old_config is None:
lines = []
else:
- text = autoconfig.read_text('utf-8')
- lines = text.splitlines()
+ data = autoconfig.read()
+ lines = autoconfig.read_raw().splitlines()
if insert:
assert lines[0].startswith('# DO NOT edit this file by hand,')
- assert 'config_version: {}'.format(yaml.VERSION) in lines
-
- assert 'global:' in lines
print(lines)
- if 'magenta' in (old_config or ''):
- assert ' colors.hints.fg: magenta' in lines
+ if old_config is None:
+ pass
+ elif 'colors.hints.fg' in old_config:
+ assert data['colors.hints.fg'] == {'global': 'magenta'}
+ elif 'content.javascript.enabled' in old_config:
+ expected = {'global': True, 'https://example.com/': False}
+ assert data['content.javascript.enabled'] == expected
+ elif 'content.images' in old_config:
+ assert data['content.images'] == {'https://example.com/': False}
+
if insert:
- assert ' tabs.show: never' in lines
+ assert data['tabs.show'] == {'global': 'never'}
def test_init_save_manager(self, yaml, fake_save_manager):
yaml.init_save_manager(fake_save_manager)
fake_save_manager.add_saveable.assert_called_with(
'yaml-config', unittest.mock.ANY, unittest.mock.ANY)
- def test_unknown_key(self, yaml, config_tmpdir):
+ def test_unknown_key(self, yaml, autoconfig):
"""An unknown setting should show an error."""
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.write_text('global:\n hello: world', encoding='utf-8')
+ autoconfig.write({'hello': {'global': 'world'}})
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
yaml.load()
@@ -127,10 +173,9 @@ class TestYaml:
assert error.text == "While loading options"
assert str(error.exception) == "Unknown option hello"
- def test_multiple_unknown_keys(self, yaml, config_tmpdir):
+ def test_multiple_unknown_keys(self, yaml, autoconfig):
"""With multiple unknown settings, all should be shown."""
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.write_text('global:\n one: 1\n two: 2', encoding='utf-8')
+ autoconfig.write({'one': {'global': 1}, 'two': {'global': 2}})
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
yaml.load()
@@ -141,23 +186,21 @@ class TestYaml:
assert str(error1.exception) == "Unknown option one"
assert str(error2.exception) == "Unknown option two"
- def test_deleted_key(self, monkeypatch, yaml, config_tmpdir):
+ def test_deleted_key(self, monkeypatch, yaml, autoconfig):
"""A key marked as deleted should be removed."""
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.write_text('global:\n hello: world', encoding='utf-8')
+ autoconfig.write({'hello': {'global': 'world'}})
monkeypatch.setattr(configdata.MIGRATIONS, 'deleted', ['hello'])
yaml.load()
yaml._save()
- lines = autoconfig.read_text('utf-8').splitlines()
- assert ' hello: world' not in lines
+ data = autoconfig.read()
+ assert not data
- def test_renamed_key(self, monkeypatch, yaml, config_tmpdir):
+ def test_renamed_key(self, monkeypatch, yaml, autoconfig):
"""A key marked as renamed should be renamed properly."""
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.write_text('global:\n old: value', encoding='utf-8')
+ autoconfig.write({'old': {'global': 'value'}})
monkeypatch.setattr(configdata.MIGRATIONS, 'renamed',
{'old': 'tabs.show'})
@@ -165,29 +208,35 @@ class TestYaml:
yaml.load()
yaml._save()
- lines = autoconfig.read_text('utf-8').splitlines()
- assert ' old: value' not in lines
- assert ' tabs.show: value' in lines
+ data = autoconfig.read()
+ assert data == {'tabs.show': {'global': 'value'}}
@pytest.mark.parametrize('persist', [True, False])
- def test_merge_persist(self, yaml, config_tmpdir, persist):
+ def test_merge_persist(self, yaml, autoconfig, persist):
"""Tests for migration of tabs.persist_mode_on_change."""
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.write_text('global:\n tabs.persist_mode_on_change: {}'.
- format(persist), encoding='utf-8')
+ autoconfig.write({'tabs.persist_mode_on_change': {'global': persist}})
yaml.load()
yaml._save()
- lines = autoconfig.read_text('utf-8').splitlines()
+ data = autoconfig.read()
+ assert 'tabs.persist_mode_on_change' not in data
mode = 'persist' if persist else 'normal'
- assert ' tabs.persist_mode_on_change:' not in lines
- assert ' tabs.mode_on_change: {}'.format(mode) in lines
+ assert data['tabs.mode_on_change']['global'] == mode
+
+ def test_bindings_default(self, yaml, autoconfig):
+ """Make sure bindings.default gets removed from autoconfig.yml."""
+ autoconfig.write({'bindings.default': {'global': '{}'}})
+
+ yaml.load()
+ yaml._save()
+
+ data = autoconfig.read()
+ assert 'bindings.default' not in data
def test_renamed_key_unknown_target(self, monkeypatch, yaml,
- config_tmpdir):
+ autoconfig):
"""A key marked as renamed with invalid name should raise an error."""
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.write_text('global:\n old: value', encoding='utf-8')
+ autoconfig.write({'old': {'global': 'value'}})
monkeypatch.setattr(configdata.MIGRATIONS, 'renamed',
{'old': 'new'})
@@ -202,7 +251,7 @@ class TestYaml:
@pytest.mark.parametrize('old_config', [
None,
- 'global:\n colors.hints.fg: magenta',
+ {'colors.hints.fg': {'global': 'magenta'}},
])
@pytest.mark.parametrize('key, value', [
('colors.hints.fg', 'green'),
@@ -210,61 +259,65 @@ class TestYaml:
('confirm_quit', True),
('confirm_quit', False),
])
- def test_changed(self, yaml, qtbot, config_tmpdir, old_config, key, value):
- autoconfig = config_tmpdir / 'autoconfig.yml'
+ def test_changed(self, yaml, qtbot, autoconfig,
+ old_config, key, value):
if old_config is not None:
- autoconfig.write_text(old_config, 'utf-8')
+ autoconfig.write(old_config)
yaml.load()
with qtbot.wait_signal(yaml.changed):
- yaml[key] = value
+ yaml.set_obj(key, value)
- assert key in yaml
- assert yaml[key] == value
+ assert yaml._values[key].get_for_url(fallback=False) == value
yaml._save()
yaml = configfiles.YamlConfig()
yaml.load()
- assert key in yaml
- assert yaml[key] == value
+ assert yaml._values[key].get_for_url(fallback=False) == value
def test_iter(self, yaml):
- yaml['foo'] = 23
- yaml['bar'] = 42
- assert list(iter(yaml)) == [('bar', 42), ('foo', 23)]
+ assert list(iter(yaml)) == list(iter(yaml._values.values()))
@pytest.mark.parametrize('old_config', [
None,
- 'global:\n colors.hints.fg: magenta',
+ {'colors.hints.fg': {'global': 'magenta'}},
])
- def test_unchanged(self, yaml, config_tmpdir, old_config):
- autoconfig = config_tmpdir / 'autoconfig.yml'
+ def test_unchanged(self, yaml, autoconfig, old_config):
mtime = None
if old_config is not None:
- autoconfig.write_text(old_config, 'utf-8')
- mtime = autoconfig.stat().mtime
+ autoconfig.write(old_config)
+ mtime = autoconfig.fobj.stat().mtime
yaml.load()
yaml._save()
if old_config is None:
- assert not autoconfig.exists()
+ assert not autoconfig.fobj.exists()
else:
- assert autoconfig.stat().mtime == mtime
+ assert autoconfig.fobj.stat().mtime == mtime
@pytest.mark.parametrize('line, text, exception', [
('%', 'While parsing', 'while scanning a directive'),
- ('global: 42', 'While loading data', "'global' object is not a dict"),
- ('foo: 42', 'While loading data',
- "Toplevel object does not contain 'global' key"),
+ ('settings: 42\nconfig_version: 2',
+ 'While loading data', "'settings' object is not a dict"),
+ ('foo: 42\nconfig_version: 2', 'While loading data',
+ "Toplevel object does not contain 'settings' key"),
+ ('settings: {}', 'While loading data',
+ "Toplevel object does not contain 'config_version' key"),
('42', 'While loading data', "Toplevel object is not a dict"),
+ ('settings: {"content.images": 42}\nconfig_version: 2',
+ "While parsing 'content.images'", "value is not a dict"),
+ ('settings: {"content.images": {"https://": true}}\nconfig_version: 2',
+ "While parsing pattern 'https://' for 'content.images'",
+ "Pattern without host"),
+ ('settings: {"content.images": {true: true}}\nconfig_version: 2',
+ "While parsing 'content.images'", "pattern is not of type string"),
])
- def test_invalid(self, yaml, config_tmpdir, line, text, exception):
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.write_text(line, 'utf-8', ensure=True)
+ def test_invalid(self, yaml, autoconfig, line, text, exception):
+ autoconfig.write_raw(line)
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
yaml.load()
@@ -275,11 +328,44 @@ class TestYaml:
assert str(error.exception).splitlines()[0] == exception
assert error.traceback is None
- def test_oserror(self, yaml, config_tmpdir):
- autoconfig = config_tmpdir / 'autoconfig.yml'
- autoconfig.ensure()
- autoconfig.chmod(0)
- if os.access(str(autoconfig), os.R_OK):
+ def test_legacy_migration(self, yaml, autoconfig, qtbot):
+ autoconfig.write_toplevel({
+ 'config_version': 1,
+ 'global': {'content.javascript.enabled': True},
+ })
+ with qtbot.wait_signal(yaml.changed):
+ yaml.load()
+
+ yaml._save()
+
+ data = autoconfig.read_toplevel()
+ assert data == {
+ 'config_version': 2,
+ 'settings': {
+ 'content.javascript.enabled': {
+ 'global': True,
+ }
+ }
+ }
+
+ def test_read_newer_version(self, yaml, autoconfig):
+ autoconfig.write_toplevel({
+ 'config_version': 999,
+ 'settings': {},
+ })
+ with pytest.raises(configexc.ConfigFileErrors) as excinfo:
+ yaml.load()
+
+ assert len(excinfo.value.errors) == 1
+ error = excinfo.value.errors[0]
+ assert error.text == "While reading"
+ msg = "Can't read config from incompatible newer version"
+ assert error.exception == msg
+
+ def test_oserror(self, yaml, autoconfig):
+ autoconfig.fobj.ensure()
+ autoconfig.fobj.chmod(0)
+ if os.access(str(autoconfig.fobj), os.R_OK):
# Docker container or similar
pytest.skip("File was still readable")
@@ -292,22 +378,22 @@ class TestYaml:
assert isinstance(error.exception, OSError)
assert error.traceback is None
- def test_unset(self, yaml, qtbot, config_tmpdir):
+ def test_unset(self, yaml, qtbot):
name = 'tabs.show'
- yaml[name] = 'never'
+ yaml.set_obj(name, 'never')
with qtbot.wait_signal(yaml.changed):
yaml.unset(name)
assert name not in yaml
- def test_unset_never_set(self, yaml, qtbot, config_tmpdir):
+ def test_unset_never_set(self, yaml, qtbot):
with qtbot.assert_not_emitted(yaml.changed):
yaml.unset('tabs.show')
- def test_clear(self, yaml, qtbot, config_tmpdir):
+ def test_clear(self, yaml, qtbot):
name = 'tabs.show'
- yaml[name] = 'never'
+ yaml.set_obj(name, 'never')
with qtbot.wait_signal(yaml.changed):
yaml.clear()
@@ -370,7 +456,7 @@ class TestConfigPyModules:
confpy.write_qbmodule()
confpy.read()
expected = {'normal': {',a': 'message-info foo'}}
- assert config.instance._values['bindings.commands'] == expected
+ assert config.instance.get_obj('bindings.commands') == expected
assert "qbmodule" not in sys.modules.keys()
assert tmpdir not in sys.path
@@ -432,16 +518,41 @@ class TestConfigPy:
@pytest.mark.parametrize('line', [
'c.colors.hints.bg = "red"',
'config.set("colors.hints.bg", "red")',
+ 'config.set("colors.hints.bg", "red", pattern=None)',
])
def test_set(self, confpy, line):
confpy.write(line)
confpy.read()
- assert config.instance._values['colors.hints.bg'] == 'red'
+ assert config.instance.get_obj('colors.hints.bg') == 'red'
+
+ @pytest.mark.parametrize('template', [
+ "config.set({opt!r}, False, {pattern!r})",
+ "with config.pattern({pattern!r}) as p: p.{opt} = False",
+ ])
+ def test_set_with_pattern(self, confpy, template):
+ option = 'content.javascript.enabled'
+ pattern = 'https://www.example.com/'
+
+ confpy.write(template.format(opt=option, pattern=pattern))
+ confpy.read()
+
+ assert config.instance.get_obj(option)
+ assert not config.instance.get_obj_for_pattern(
+ option, pattern=urlmatch.UrlPattern(pattern))
+
+ def test_set_context_manager_global(self, confpy):
+ """When "with config.pattern" is used, "c." should still be global."""
+ option = 'content.javascript.enabled'
+ confpy.write('with config.pattern("https://www.example.com/") as p:'
+ ' c.{} = False'.format(option))
+ confpy.read()
+ assert not config.instance.get_obj(option)
@pytest.mark.parametrize('set_first', [True, False])
@pytest.mark.parametrize('get_line', [
'c.colors.hints.fg',
'config.get("colors.hints.fg")',
+ 'config.get("colors.hints.fg", pattern=None)',
])
def test_get(self, confpy, set_first, get_line):
"""Test whether getting options works correctly."""
@@ -455,6 +566,24 @@ class TestConfigPy:
confpy.write('assert {} == "green"'.format(get_line))
confpy.read()
+ def test_get_with_pattern(self, confpy):
+ """Test whether we get a matching value with a pattern."""
+ option = 'content.javascript.enabled'
+ pattern = 'https://www.example.com/'
+ config.instance.set_obj(option, False,
+ pattern=urlmatch.UrlPattern(pattern))
+ confpy.write('assert config.get({!r})'.format(option),
+ 'assert not config.get({!r}, pattern={!r})'
+ .format(option, pattern))
+ confpy.read()
+
+ def test_get_with_pattern_no_match(self, confpy):
+ confpy.write(
+ 'val = config.get("content.images", "https://www.example.com")',
+ 'assert val is True',
+ )
+ confpy.read()
+
@pytest.mark.parametrize('line, mode', [
('config.bind(",a", "message-info foo")', 'normal'),
('config.bind(",a", "message-info foo", "prompt")', 'prompt'),
@@ -463,7 +592,7 @@ class TestConfigPy:
confpy.write(line)
confpy.read()
expected = {mode: {',a': 'message-info foo'}}
- assert config.instance._values['bindings.commands'] == expected
+ assert config.instance.get_obj('bindings.commands') == expected
def test_bind_freshly_defined_alias(self, confpy):
"""Make sure we can bind to a new alias.
@@ -479,31 +608,31 @@ class TestConfigPy:
confpy.write("config.bind('H', 'message-info back')")
confpy.read()
expected = {'normal': {'H': 'message-info back'}}
- assert config.instance._values['bindings.commands'] == expected
+ assert config.instance.get_obj('bindings.commands') == expected
def test_bind_none(self, confpy):
confpy.write("c.bindings.commands = None",
"config.bind(',x', 'nop')")
confpy.read()
expected = {'normal': {',x': 'nop'}}
- assert config.instance._values['bindings.commands'] == expected
+ assert config.instance.get_obj('bindings.commands') == expected
@pytest.mark.parametrize('line, key, mode', [
('config.unbind("o")', 'o', 'normal'),
- ('config.unbind("y", mode="prompt")', 'y', 'prompt'),
+ ('config.unbind("y", mode="yesno")', 'y', 'yesno'),
])
def test_unbind(self, confpy, line, key, mode):
confpy.write(line)
confpy.read()
expected = {mode: {key: None}}
- assert config.instance._values['bindings.commands'] == expected
+ assert config.instance.get_obj('bindings.commands') == expected
def test_mutating(self, confpy):
confpy.write('c.aliases["foo"] = "message-info foo"',
'c.aliases["bar"] = "message-info bar"')
confpy.read()
- assert config.instance._values['aliases']['foo'] == 'message-info foo'
- assert config.instance._values['aliases']['bar'] == 'message-info bar'
+ assert config.instance.get_obj('aliases')['foo'] == 'message-info foo'
+ assert config.instance.get_obj('aliases')['bar'] == 'message-info bar'
@pytest.mark.parametrize('option, value', [
('content.user_stylesheets', 'style.css'),
@@ -517,7 +646,7 @@ class TestConfigPy:
(config_tmpdir / 'style.css').ensure()
confpy.write('c.{}.append("{}")'.format(option, value))
confpy.read()
- assert config.instance._values[option][-1] == value
+ assert config.instance.get_obj(option)[-1] == value
def test_oserror(self, tmpdir, data_tmpdir, config_tmpdir):
with pytest.raises(configexc.ConfigFileErrors) as excinfo:
@@ -550,7 +679,7 @@ class TestConfigPy:
assert len(excinfo.value.errors) == 1
error = excinfo.value.errors[0]
assert isinstance(error.exception, SyntaxError)
- assert error.text == "Syntax Error"
+ assert error.text == "Unhandled exception"
exception_text = 'invalid syntax (config.py, line 1)'
assert str(error.exception) == exception_text
@@ -582,6 +711,20 @@ class TestConfigPy:
message = "'ConfigAPI' object has no attribute 'val'"
assert str(error.exception) == message
+ @pytest.mark.parametrize('line', [
+ 'config.bind("<blub>", "nop")',
+ 'config.bind("\U00010000", "nop")',
+ 'config.unbind("<blub>")',
+ 'config.unbind("\U00010000")',
+ ])
+ def test_invalid_keys(self, confpy, line):
+ confpy.write(line)
+ error = confpy.read(error=True)
+ assert error.text.endswith("and parsing key")
+ assert isinstance(error.exception, keyutils.KeyParseError)
+ assert str(error.exception).startswith("Could not parse")
+ assert str(error.exception).endswith("Got invalid key!")
+
@pytest.mark.parametrize('line', ["c.foo = 42", "config.set('foo', 42)"])
def test_config_error(self, confpy, line):
confpy.write(line)
@@ -604,6 +747,22 @@ class TestConfigPy:
"'qt.args')")
assert str(error.exception) == expected
+ @pytest.mark.parametrize('line, text', [
+ ('config.get("content.images", "http://")',
+ "While getting 'content.images' and parsing pattern"),
+ ('config.set("content.images", False, "http://")',
+ "While setting 'content.images' and parsing pattern"),
+ ('with config.pattern("http://"): pass',
+ "Unhandled exception"),
+ ])
+ def test_invalid_pattern(self, confpy, line, text):
+ confpy.write(line)
+ error = confpy.read(error=True)
+
+ assert error.text == text
+ assert isinstance(error.exception, urlmatch.ParseError)
+ assert str(error.exception) == "Pattern without host"
+
def test_multiple_errors(self, confpy):
confpy.write("c.foo = 42", "config.set('foo', 42)", "1/0")
@@ -638,7 +797,7 @@ class TestConfigPy:
confpy.write("config.source({!r})".format(arg))
confpy.read()
- assert not config.instance._values['content.javascript.enabled']
+ assert not config.instance.get_obj('content.javascript.enabled')
def test_source_errors(self, tmpdir, confpy):
subfile = tmpdir / 'config' / 'subfile.py'
@@ -682,7 +841,7 @@ class TestConfigPyWriter:
name='opt', typ=configtypes.Int(), default='def',
backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
description=desc)
- options = [(opt, 'val')]
+ options = [(None, opt, 'val')]
bindings = {'normal': {',x': 'message-info normal'},
'caret': {',y': 'message-info caret'}}
@@ -714,8 +873,8 @@ class TestConfigPyWriter:
def test_binding_options_hidden(self):
opt1 = configdata.DATA['bindings.default']
opt2 = configdata.DATA['bindings.commands']
- options = [(opt1, {'normal': {'x': 'message-info x'}}),
- (opt2, {})]
+ options = [(None, opt1, {'normal': {'x': 'message-info x'}}),
+ (None, opt2, {})]
writer = configfiles.ConfigPyWriter(options, bindings={},
commented=False)
text = '\n'.join(writer._gen_lines())
@@ -727,7 +886,7 @@ class TestConfigPyWriter:
name='opt', typ=configtypes.Int(), default='def',
backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
description='Hello World')
- options = [(opt, 'val')]
+ options = [(None, opt, 'val')]
bindings = {'normal': {',x': 'message-info normal'},
'caret': {',y': 'message-info caret'}}
@@ -753,7 +912,7 @@ class TestConfigPyWriter:
backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
description='All colors are beautiful!')
- options = [(opt1, 'ask'), (opt2, 'rgb')]
+ options = [(None, opt1, 'ask'), (None, opt2, 'rgb')]
writer = configfiles.ConfigPyWriter(options, bindings={},
commented=False)
@@ -794,6 +953,20 @@ class TestConfigPyWriter:
""").lstrip()
assert text == expected
+ def test_pattern(self):
+ opt = configdata.Option(
+ name='opt', typ=configtypes.BoolAsk(), default='ask',
+ backends=[usertypes.Backend.QtWebEngine], raw_backends=None,
+ description='Hello World')
+ options = [
+ (urlmatch.UrlPattern('https://www.example.com/'), opt, 'ask'),
+ ]
+ writer = configfiles.ConfigPyWriter(options=options, bindings={},
+ commented=False)
+ text = '\n'.join(writer._gen_lines())
+ expected = "config.set('opt', 'ask', 'https://www.example.com/')"
+ assert expected in text
+
def test_write(self, tmpdir):
pyfile = tmpdir / 'config.py'
writer = configfiles.ConfigPyWriter(options=[], bindings={},
@@ -804,7 +977,7 @@ class TestConfigPyWriter:
def test_defaults_work(self, confpy):
"""Get a config.py with default values and run it."""
- options = [(opt, opt.default)
+ options = [(None, opt, opt.default)
for _name, opt in sorted(configdata.DATA.items())]
bindings = dict(configdata.DATA['bindings.default'].default)
writer = configfiles.ConfigPyWriter(options, bindings, commented=False)
diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py
index a845f84f2..e7d217d8e 100644
--- a/tests/unit/config/test_configinit.py
+++ b/tests/unit/config/test_configinit.py
@@ -100,14 +100,15 @@ class TestEarlyInit:
# Check config values
if config_py:
- assert config.instance._values == {'colors.hints.bg': 'red'}
+ expected = 'colors.hints.bg = red'
else:
- assert config.instance._values == {}
+ expected = '<Default configuration>'
+ assert config.instance.dump_userconfig() == expected
@pytest.mark.parametrize('load_autoconfig', [True, False]) # noqa
@pytest.mark.parametrize('config_py', [True, 'error', False])
- @pytest.mark.parametrize('invalid_yaml', ['42', 'unknown', 'wrong-type',
- False])
+ @pytest.mark.parametrize('invalid_yaml', ['42', 'list', 'unknown',
+ 'wrong-type', False])
def test_autoconfig_yml(self, init_patch, config_tmpdir, caplog, args,
load_autoconfig, config_py, invalid_yaml):
"""Test interaction between config.py and autoconfig.yml."""
@@ -115,14 +116,30 @@ class TestEarlyInit:
autoconfig_file = config_tmpdir / 'autoconfig.yml'
config_py_file = config_tmpdir / 'config.py'
- yaml_text = {
+ yaml_lines = {
'42': '42',
- 'unknown': 'global:\n colors.foobar: magenta\n',
- 'wrong-type': 'global:\n tabs.position: true\n',
- False: 'global:\n colors.hints.fg: magenta\n',
+ 'list': '[1, 2]',
+ 'unknown': [
+ 'settings:',
+ ' colors.foobar:',
+ ' global: magenta',
+ 'config_version: 2',
+ ],
+ 'wrong-type': [
+ 'settings:',
+ ' tabs.position:',
+ ' global: true',
+ 'config_version: 2',
+ ],
+ False: [
+ 'settings:',
+ ' colors.hints.fg:',
+ ' global: magenta',
+ 'config_version: 2',
+ ],
}
- autoconfig_file.write_text(yaml_text[invalid_yaml], 'utf-8',
- ensure=True)
+ text = '\n'.join(yaml_lines[invalid_yaml])
+ autoconfig_file.write_text(text, 'utf-8', ensure=True)
if config_py:
config_py_lines = ['c.colors.hints.bg = "red"']
@@ -141,7 +158,7 @@ class TestEarlyInit:
if load_autoconfig or not config_py:
suffix = ' (autoconfig.yml)' if config_py else ''
- if invalid_yaml == '42':
+ if invalid_yaml in ['42', 'list']:
error = ("While loading data{}: Toplevel object is not a dict"
.format(suffix))
expected_errors.append(error)
@@ -165,17 +182,21 @@ class TestEarlyInit:
assert actual_errors == expected_errors
# Check config values
+ dump = config.instance.dump_userconfig()
+
if config_py and load_autoconfig and not invalid_yaml:
- assert config.instance._values == {
- 'colors.hints.bg': 'red',
- 'colors.hints.fg': 'magenta',
- }
+ expected = [
+ 'colors.hints.bg = red',
+ 'colors.hints.fg = magenta',
+ ]
elif config_py:
- assert config.instance._values == {'colors.hints.bg': 'red'}
+ expected = ['colors.hints.bg = red']
elif invalid_yaml:
- assert config.instance._values == {}
+ expected = ['<Default configuration>']
else:
- assert config.instance._values == {'colors.hints.fg': 'magenta'}
+ expected = ['colors.hints.fg = magenta']
+
+ assert dump == '\n'.join(expected)
def test_invalid_change_filter(self, init_patch, args):
config.change_filter('foobar')
@@ -185,7 +206,7 @@ class TestEarlyInit:
def test_temp_settings_valid(self, init_patch, args):
args.temp_settings = [('colors.completion.fg', 'magenta')]
configinit.early_init(args)
- assert config.instance._values['colors.completion.fg'] == 'magenta'
+ assert config.instance.get_obj('colors.completion.fg') == 'magenta'
def test_temp_settings_invalid(self, caplog, init_patch, message_mock,
args):
@@ -198,7 +219,6 @@ class TestEarlyInit:
msg = message_mock.getmsg()
assert msg.level == usertypes.MessageLevel.error
assert msg.text == "set: NoOptionError - No option 'foo'"
- assert 'colors.completion.fg' not in config.instance._values
@pytest.mark.parametrize('settings, size, family', [
# Only fonts.monospace customized
@@ -220,8 +240,9 @@ class TestEarlyInit:
args.temp_settings = settings
elif method == 'auto':
autoconfig_file = config_tmpdir / 'autoconfig.yml'
- lines = ["global:"] + [" {}: '{}'".format(k, v)
- for k, v in settings]
+ lines = (["config_version: 2", "settings:"] +
+ [" {}:\n global:\n '{}'".format(k, v)
+ for k, v in settings])
autoconfig_file.write_text('\n'.join(lines), 'utf-8', ensure=True)
elif method == 'py':
config_py_file = config_tmpdir / 'config.py'
diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py
index 81a7d53e1..533932981 100644
--- a/tests/unit/config/test_configtypes.py
+++ b/tests/unit/config/test_configtypes.py
@@ -37,6 +37,7 @@ from PyQt5.QtNetwork import QNetworkProxy
from qutebrowser.config import configtypes, configexc
from qutebrowser.utils import debug, utils, qtutils
from qutebrowser.browser.network import pac
+from qutebrowser.keyinput import keyutils
from tests.helpers import utils as testutils
@@ -532,6 +533,17 @@ class FlagListSubclass(configtypes.FlagList):
'foo', 'bar', 'baz')
+class FromObjType(configtypes.BaseType):
+
+ """Config type to test from_obj for List/Dict."""
+
+ def from_obj(self, value):
+ return int(value)
+
+ def to_py(self, value):
+ return value
+
+
class TestList:
"""Test List and FlagList."""
@@ -646,6 +658,12 @@ class TestList:
with pytest.raises(AssertionError):
typ.to_doc([['foo']])
+ def test_from_obj_sub(self):
+ """Make sure the list calls from_obj() on sub-types."""
+ typ = configtypes.List(valtype=FromObjType())
+ value = typ.from_obj(['1', '2'])
+ assert value == [1, 2]
+
class TestFlagList:
@@ -1664,6 +1682,13 @@ class TestDict:
print(doc)
assert doc == expected
+ def test_from_obj_sub(self):
+ """Make sure the dict calls from_obj() on sub-types."""
+ typ = configtypes.Dict(keytype=configtypes.String(),
+ valtype=FromObjType())
+ value = typ.from_obj({'1': '2'})
+ assert value == {'1': 2}
+
def unrequired_class(**kwargs):
return configtypes.File(required=False, **kwargs)
@@ -2058,12 +2083,21 @@ class TestKey:
return configtypes.Key
@pytest.mark.parametrize('val, expected', [
- ('gC', 'gC'),
- ('<Control-x>', '<ctrl+x>')
+ ('gC', keyutils.KeySequence.parse('gC')),
+ ('<Control-x>', keyutils.KeySequence.parse('<ctrl+x>')),
+ ('<alt-1>', keyutils.KeySequence.parse('<alt+1>')),
+ ('0', keyutils.KeySequence.parse('0')),
+ ('1', keyutils.KeySequence.parse('1')),
+ ('a1', keyutils.KeySequence.parse('a1')),
])
def test_to_py_valid(self, klass, val, expected):
assert klass().to_py(val) == expected
+ @pytest.mark.parametrize('val', ['\U00010000', '<blub>'])
+ def test_to_py_invalid(self, klass, val):
+ with pytest.raises(configexc.ValidationError):
+ klass().to_py(val)
+
@pytest.mark.parametrize('first, second, equal', [
(re.compile('foo'), RegexEq('foo'), True),
diff --git a/tests/unit/config/test_configutils.py b/tests/unit/config/test_configutils.py
new file mode 100644
index 000000000..587a0bd68
--- /dev/null
+++ b/tests/unit/config/test_configutils.py
@@ -0,0 +1,210 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+import pytest
+
+from PyQt5.QtCore import QUrl
+
+from qutebrowser.config import configutils, configdata, configtypes
+from qutebrowser.utils import urlmatch
+
+
+def test_unset_object_identity():
+ assert configutils._UnsetObject() is not configutils._UnsetObject()
+ assert configutils.UNSET is configutils.UNSET
+
+
+def test_unset_object_repr():
+ assert repr(configutils.UNSET) == '<UNSET>'
+
+
+@pytest.fixture
+def opt():
+ return configdata.Option(name='example.option', typ=configtypes.String(),
+ default='default value', backends=None,
+ raw_backends=None, description=None,
+ supports_pattern=True)
+
+
+@pytest.fixture
+def pattern():
+ return urlmatch.UrlPattern('*://www.example.com/')
+
+
+@pytest.fixture
+def other_pattern():
+ return urlmatch.UrlPattern('https://www.example.org/')
+
+
+@pytest.fixture
+def values(opt, pattern):
+ scoped_values = [configutils.ScopedValue('global value', None),
+ configutils.ScopedValue('example value', pattern)]
+ return configutils.Values(opt, scoped_values)
+
+
+@pytest.fixture
+def empty_values(opt):
+ return configutils.Values(opt)
+
+
+def test_repr(opt, values):
+ expected = ("qutebrowser.config.configutils.Values(opt={!r}, "
+ "values=[ScopedValue(value='global value', pattern=None), "
+ "ScopedValue(value='example value', pattern=qutebrowser.utils."
+ "urlmatch.UrlPattern(pattern='*://www.example.com/'))])"
+ .format(opt))
+ assert repr(values) == expected
+
+
+def test_str(values):
+ expected = [
+ 'example.option = global value',
+ '*://www.example.com/: example.option = example value',
+ ]
+ assert str(values) == '\n'.join(expected)
+
+
+def test_str_empty(empty_values):
+ assert str(empty_values) == 'example.option: <unchanged>'
+
+
+def test_bool(values, empty_values):
+ assert values
+ assert not empty_values
+
+
+def test_iter(values):
+ assert list(iter(values)) == list(iter(values._values))
+
+
+def test_add_existing(values):
+ values.add('new global value')
+ assert values.get_for_url() == 'new global value'
+
+
+def test_add_new(values, other_pattern):
+ values.add('example.org value', other_pattern)
+ assert values.get_for_url() == 'global value'
+ example_com = QUrl('https://www.example.com/')
+ example_org = QUrl('https://www.example.org/')
+ assert values.get_for_url(example_com) == 'example value'
+ assert values.get_for_url(example_org) == 'example.org value'
+
+
+def test_remove_existing(values, pattern):
+ removed = values.remove(pattern)
+ assert removed
+
+ url = QUrl('https://www.example.com/')
+ assert values.get_for_url(url) == 'global value'
+
+
+def test_remove_non_existing(values, other_pattern):
+ removed = values.remove(other_pattern)
+ assert not removed
+
+ url = QUrl('https://www.example.com/')
+ assert values.get_for_url(url) == 'example value'
+
+
+def test_clear(values):
+ assert values
+ values.clear()
+ assert not values
+ assert values.get_for_url(fallback=False) is configutils.UNSET
+
+
+def test_get_matching(values):
+ url = QUrl('https://www.example.com/')
+ assert values.get_for_url(url, fallback=False) == 'example value'
+
+
+def test_get_unset(empty_values):
+ assert empty_values.get_for_url(fallback=False) is configutils.UNSET
+
+
+def test_get_no_global(empty_values, other_pattern):
+ empty_values.add('example.org value', pattern)
+ assert empty_values.get_for_url(fallback=False) is configutils.UNSET
+
+
+def test_get_unset_fallback(empty_values):
+ assert empty_values.get_for_url() == 'default value'
+
+
+def test_get_non_matching(values):
+ url = QUrl('https://www.example.ch/')
+ assert values.get_for_url(url, fallback=False) is configutils.UNSET
+
+
+def test_get_non_matching_fallback(values):
+ url = QUrl('https://www.example.ch/')
+ assert values.get_for_url(url) == 'global value'
+
+
+def test_get_multiple_matches(values):
+ """With multiple matching pattern, the last added should win."""
+ all_pattern = urlmatch.UrlPattern('*://*/')
+ values.add('new value', all_pattern)
+ url = QUrl('https://www.example.com/')
+ assert values.get_for_url(url) == 'new value'
+
+
+def test_get_matching_pattern(values, pattern):
+ assert values.get_for_pattern(pattern, fallback=False) == 'example value'
+
+
+def test_get_pattern_none(values, pattern):
+ assert values.get_for_pattern(None, fallback=False) == 'global value'
+
+
+def test_get_unset_pattern(empty_values, pattern):
+ value = empty_values.get_for_pattern(pattern, fallback=False)
+ assert value is configutils.UNSET
+
+
+def test_get_no_global_pattern(empty_values, pattern, other_pattern):
+ empty_values.add('example.org value', other_pattern)
+ value = empty_values.get_for_pattern(pattern, fallback=False)
+ assert value is configutils.UNSET
+
+
+def test_get_unset_fallback_pattern(empty_values, pattern):
+ assert empty_values.get_for_pattern(pattern) == 'default value'
+
+
+def test_get_non_matching_pattern(values, other_pattern):
+ value = values.get_for_pattern(other_pattern, fallback=False)
+ assert value is configutils.UNSET
+
+
+def test_get_non_matching_fallback_pattern(values, other_pattern):
+ assert values.get_for_pattern(other_pattern) == 'global value'
+
+
+def test_get_equivalent_patterns(empty_values):
+ """With multiple matching pattern, the last added should win."""
+ pat1 = urlmatch.UrlPattern('https://www.example.com/')
+ pat2 = urlmatch.UrlPattern('*://www.example.com/')
+ empty_values.add('pat1 value', pat1)
+ empty_values.add('pat2 value', pat2)
+
+ assert empty_values.get_for_pattern(pat1) == 'pat1 value'
+ assert empty_values.get_for_pattern(pat2) == 'pat2 value'
diff --git a/tests/unit/javascript/test_greasemonkey.py b/tests/unit/javascript/test_greasemonkey.py
index 52af51a4b..7759f5d18 100644
--- a/tests/unit/javascript/test_greasemonkey.py
+++ b/tests/unit/javascript/test_greasemonkey.py
@@ -128,3 +128,33 @@ def test_load_emits_signal(qtbot):
gm_manager = greasemonkey.GreasemonkeyManager()
with qtbot.wait_signal(gm_manager.scripts_reloaded):
gm_manager.load_scripts()
+
+
+def test_required_scripts_are_included(download_stub, tmpdir):
+ test_require_script = textwrap.dedent("""
+ // ==UserScript==
+ // @name qutebrowser test userscript
+ // @namespace invalid.org
+ // @include http://localhost:*/data/title.html
+ // @match http://trolol*
+ // @exclude https://badhost.xxx/*
+ // @run-at document-start
+ // @require http://localhost/test.js
+ // ==/UserScript==
+ console.log("Script is running.");
+ """)
+ _save_script(test_require_script, 'requiring.user.js')
+ with open(str(tmpdir / 'test.js'), 'w', encoding='UTF-8') as f:
+ f.write("REQUIRED SCRIPT")
+
+ gm_manager = greasemonkey.GreasemonkeyManager()
+ assert len(gm_manager._in_progress_dls) == 1
+ for download in gm_manager._in_progress_dls:
+ download.finished.emit()
+
+ scripts = gm_manager.all_scripts()
+ assert len(scripts) == 1
+ assert "REQUIRED SCRIPT" in scripts[0].code()
+ # Additionally check that the base script is still being parsed correctly
+ assert "Script is running." in scripts[0].code()
+ assert scripts[0].excludes
diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py
index 0713c5d26..045b62336 100644
--- a/tests/unit/keyinput/conftest.py
+++ b/tests/unit/keyinput/conftest.py
@@ -21,19 +21,24 @@
import pytest
+from PyQt5.QtCore import QEvent, Qt
+from PyQt5.QtGui import QKeyEvent
+
+from qutebrowser.keyinput import keyutils
+
BINDINGS = {'prompt': {'<Ctrl-a>': 'message-info ctrla',
'a': 'message-info a',
'ba': 'message-info ba',
'ax': 'message-info ax',
'ccc': 'message-info ccc',
- '0': 'message-info 0'},
+ 'yY': 'yank -s',
+ '0': 'message-info 0',
+ '1': 'message-info 1'},
'command': {'foo': 'message-info bar',
'<Ctrl+X>': 'message-info ctrlx'},
'normal': {'a': 'message-info a', 'ba': 'message-info ba'}}
MAPPINGS = {
- '<Ctrl+a>': 'a',
- '<Ctrl+b>': '<Ctrl+a>',
'x': 'a',
'b': 'a',
}
@@ -45,3 +50,14 @@ def keyinput_bindings(config_stub, key_config_stub):
config_stub.val.bindings.default = {}
config_stub.val.bindings.commands = dict(BINDINGS)
config_stub.val.bindings.key_mappings = dict(MAPPINGS)
+
+
+@pytest.fixture
+def fake_keyevent():
+ """Fixture that when called will return a mock instance of a QKeyEvent."""
+ def func(key, modifiers=Qt.NoModifier, typ=QEvent.KeyPress):
+ """Generate a new fake QKeyPressEvent."""
+ text = keyutils.KeyInfo(key, modifiers).text()
+ return QKeyEvent(QKeyEvent.KeyPress, key, modifiers, text)
+
+ return func
diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py
new file mode 100644
index 000000000..bf1ccdede
--- /dev/null
+++ b/tests/unit/keyinput/key_data.py
@@ -0,0 +1,623 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+# pylint: disable=line-too-long
+
+
+"""Data used by test_keyutils.py to test all keys."""
+
+
+import attr
+from PyQt5.QtCore import Qt
+
+
+@attr.s
+class Key:
+
+ """A key with expected values.
+
+ Attributes:
+ attribute: The name of the Qt::Key attribute ('Foo' -> Qt.Key_Foo)
+ name: The name returned by str(KeyInfo) with that key.
+ text: The text returned by KeyInfo.text().
+ uppertext: The text returned by KeyInfo.text() with shift.
+ member: The numeric value.
+ """
+
+ attribute = attr.ib()
+ name = attr.ib(None)
+ text = attr.ib('')
+ uppertext = attr.ib('')
+ member = attr.ib(None)
+ qtest = attr.ib(True)
+
+ def __attrs_post_init__(self):
+ if self.attribute:
+ self.member = getattr(Qt, 'Key_' + self.attribute, None)
+ if self.name is None:
+ self.name = self.attribute
+
+
+@attr.s
+class Modifier:
+
+ """A modifier with expected values.
+
+ Attributes:
+ attribute: The name of the Qt::KeyboardModifier attribute
+ ('Shift' -> Qt.ShiftModifier)
+ name: The name returned by str(KeyInfo) with that modifier.
+ member: The numeric value.
+ """
+
+ attribute = attr.ib()
+ name = attr.ib(None)
+ member = attr.ib(None)
+
+ def __attrs_post_init__(self):
+ self.member = getattr(Qt, self.attribute + 'Modifier')
+ if self.name is None:
+ self.name = self.attribute
+
+
+# From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h
+KEYS = [
+ ### misc keys
+ Key('Escape', text='\x1b', uppertext='\x1b'),
+ Key('Tab', text='\t', uppertext='\t'),
+ Key('Backtab', qtest=False), # Qt assumes VT (vertical tab)
+ Key('Backspace', text='\b', uppertext='\b'),
+ Key('Return', text='\r', uppertext='\r'),
+ Key('Enter', text='\r', uppertext='\r'),
+ Key('Insert', 'Ins'),
+ Key('Delete', 'Del'),
+ Key('Pause'),
+ Key('Print'), # print screen
+ Key('SysReq'),
+ Key('Clear'),
+ ### cursor movement
+ Key('Home'),
+ Key('End'),
+ Key('Left'),
+ Key('Up'),
+ Key('Right'),
+ Key('Down'),
+ Key('PageUp', 'PgUp'),
+ Key('PageDown', 'PgDown'),
+ ### modifiers
+ Key('Shift'),
+ Key('Control'),
+ Key('Meta'),
+ Key('Alt'),
+ Key('CapsLock'),
+ Key('NumLock'),
+ Key('ScrollLock'),
+ ### function keys
+ Key('F1'),
+ Key('F2'),
+ Key('F3'),
+ Key('F4'),
+ Key('F5'),
+ Key('F6'),
+ Key('F7'),
+ Key('F8'),
+ Key('F9'),
+ Key('F10'),
+ Key('F11'),
+ Key('F12'),
+ Key('F13'),
+ Key('F14'),
+ Key('F15'),
+ Key('F16'),
+ Key('F17'),
+ Key('F18'),
+ Key('F19'),
+ Key('F20'),
+ Key('F21'),
+ Key('F22'),
+ Key('F23'),
+ Key('F24'),
+ # F25 .. F35 only on X11
+ Key('F25'),
+ Key('F26'),
+ Key('F27'),
+ Key('F28'),
+ Key('F29'),
+ Key('F30'),
+ Key('F31'),
+ Key('F32'),
+ Key('F33'),
+ Key('F34'),
+ Key('F35'),
+ ### extra keys
+ Key('Super_L', 'Super L'),
+ Key('Super_R', 'Super R'),
+ Key('Menu'),
+ Key('Hyper_L', 'Hyper L'),
+ Key('Hyper_R', 'Hyper R'),
+ Key('Help'),
+ Key('Direction_L', 'Direction L'),
+ Key('Direction_R', 'Direction R'),
+ ### 7 bit printable ASCII
+ Key('Space', text=' ', uppertext=' '),
+ Key('Any', 'Space', text=' ', uppertext=' '), # Same value
+ Key('Exclam', '!', text='!', uppertext='!'),
+ Key('QuoteDbl', '"', text='"', uppertext='"'),
+ Key('NumberSign', '#', text='#', uppertext='#'),
+ Key('Dollar', '$', text='$', uppertext='$'),
+ Key('Percent', '%', text='%', uppertext='%'),
+ Key('Ampersand', '&', text='&', uppertext='&'),
+ Key('Apostrophe', "'", text="'", uppertext="'"),
+ Key('ParenLeft', '(', text='(', uppertext='('),
+ Key('ParenRight', ')', text=')', uppertext=')'),
+ Key('Asterisk', '*', text='*', uppertext='*'),
+ Key('Plus', '+', text='+', uppertext='+'),
+ Key('Comma', ',', text=',', uppertext=','),
+ Key('Minus', '-', text='-', uppertext='-'),
+ Key('Period', '.', text='.', uppertext='.'),
+ Key('Slash', '/', text='/', uppertext='/'),
+ Key('0', text='0', uppertext='0'),
+ Key('1', text='1', uppertext='1'),
+ Key('2', text='2', uppertext='2'),
+ Key('3', text='3', uppertext='3'),
+ Key('4', text='4', uppertext='4'),
+ Key('5', text='5', uppertext='5'),
+ Key('6', text='6', uppertext='6'),
+ Key('7', text='7', uppertext='7'),
+ Key('8', text='8', uppertext='8'),
+ Key('9', text='9', uppertext='9'),
+ Key('Colon', ':', text=':', uppertext=':'),
+ Key('Semicolon', ';', text=';', uppertext=';'),
+ Key('Less', '<', text='<', uppertext='<'),
+ Key('Equal', '=', text='=', uppertext='='),
+ Key('Greater', '>', text='>', uppertext='>'),
+ Key('Question', '?', text='?', uppertext='?'),
+ Key('At', '@', text='@', uppertext='@'),
+ Key('A', text='a', uppertext='A'),
+ Key('B', text='b', uppertext='B'),
+ Key('C', text='c', uppertext='C'),
+ Key('D', text='d', uppertext='D'),
+ Key('E', text='e', uppertext='E'),
+ Key('F', text='f', uppertext='F'),
+ Key('G', text='g', uppertext='G'),
+ Key('H', text='h', uppertext='H'),
+ Key('I', text='i', uppertext='I'),
+ Key('J', text='j', uppertext='J'),
+ Key('K', text='k', uppertext='K'),
+ Key('L', text='l', uppertext='L'),
+ Key('M', text='m', uppertext='M'),
+ Key('N', text='n', uppertext='N'),
+ Key('O', text='o', uppertext='O'),
+ Key('P', text='p', uppertext='P'),
+ Key('Q', text='q', uppertext='Q'),
+ Key('R', text='r', uppertext='R'),
+ Key('S', text='s', uppertext='S'),
+ Key('T', text='t', uppertext='T'),
+ Key('U', text='u', uppertext='U'),
+ Key('V', text='v', uppertext='V'),
+ Key('W', text='w', uppertext='W'),
+ Key('X', text='x', uppertext='X'),
+ Key('Y', text='y', uppertext='Y'),
+ Key('Z', text='z', uppertext='Z'),
+ Key('BracketLeft', '[', text='[', uppertext='['),
+ Key('Backslash', '\\', text='\\', uppertext='\\'),
+ Key('BracketRight', ']', text=']', uppertext=']'),
+ Key('AsciiCircum', '^', text='^', uppertext='^'),
+ Key('Underscore', '_', text='_', uppertext='_'),
+ Key('QuoteLeft', '`', text='`', uppertext='`'),
+ Key('BraceLeft', '{', text='{', uppertext='{'),
+ Key('Bar', '|', text='|', uppertext='|'),
+ Key('BraceRight', '}', text='}', uppertext='}'),
+ Key('AsciiTilde', '~', text='~', uppertext='~'),
+
+ Key('nobreakspace', ' ', text=' ', uppertext=' '),
+ Key('exclamdown', '¡', text='¡', uppertext='¡'),
+ Key('cent', '¢', text='¢', uppertext='¢'),
+ Key('sterling', '£', text='£', uppertext='£'),
+ Key('currency', '¤', text='¤', uppertext='¤'),
+ Key('yen', '¥', text='¥', uppertext='¥'),
+ Key('brokenbar', '¦', text='¦', uppertext='¦'),
+ Key('section', '§', text='§', uppertext='§'),
+ Key('diaeresis', '¨', text='¨', uppertext='¨'),
+ Key('copyright', '©', text='©', uppertext='©'),
+ Key('ordfeminine', 'ª', text='ª', uppertext='ª'),
+ Key('guillemotleft', '«', text='«', uppertext='«'),
+ Key('notsign', '¬', text='¬', uppertext='¬'),
+ Key('hyphen', '­', text='­', uppertext='­'),
+ Key('registered', '®', text='®', uppertext='®'),
+ Key('macron', '¯', text='¯', uppertext='¯'),
+ Key('degree', '°', text='°', uppertext='°'),
+ Key('plusminus', '±', text='±', uppertext='±'),
+ Key('twosuperior', '²', text='²', uppertext='²'),
+ Key('threesuperior', '³', text='³', uppertext='³'),
+ Key('acute', '´', text='´', uppertext='´'),
+ Key('mu', 'Μ', text='μ', uppertext='Μ', qtest=False), # Qt assumes U+00B5 instead of U+03BC
+ Key('paragraph', '¶', text='¶', uppertext='¶'),
+ Key('periodcentered', '·', text='·', uppertext='·'),
+ Key('cedilla', '¸', text='¸', uppertext='¸'),
+ Key('onesuperior', '¹', text='¹', uppertext='¹'),
+ Key('masculine', 'º', text='º', uppertext='º'),
+ Key('guillemotright', '»', text='»', uppertext='»'),
+ Key('onequarter', '¼', text='¼', uppertext='¼'),
+ Key('onehalf', '½', text='½', uppertext='½'),
+ Key('threequarters', '¾', text='¾', uppertext='¾'),
+ Key('questiondown', '¿', text='¿', uppertext='¿'),
+ Key('Agrave', 'À', text='à', uppertext='À'),
+ Key('Aacute', 'Á', text='á', uppertext='Á'),
+ Key('Acircumflex', 'Â', text='â', uppertext='Â'),
+ Key('Atilde', 'Ã', text='ã', uppertext='Ã'),
+ Key('Adiaeresis', 'Ä', text='ä', uppertext='Ä'),
+ Key('Aring', 'Å', text='å', uppertext='Å'),
+ Key('AE', 'Æ', text='æ', uppertext='Æ'),
+ Key('Ccedilla', 'Ç', text='ç', uppertext='Ç'),
+ Key('Egrave', 'È', text='è', uppertext='È'),
+ Key('Eacute', 'É', text='é', uppertext='É'),
+ Key('Ecircumflex', 'Ê', text='ê', uppertext='Ê'),
+ Key('Ediaeresis', 'Ë', text='ë', uppertext='Ë'),
+ Key('Igrave', 'Ì', text='ì', uppertext='Ì'),
+ Key('Iacute', 'Í', text='í', uppertext='Í'),
+ Key('Icircumflex', 'Î', text='î', uppertext='Î'),
+ Key('Idiaeresis', 'Ï', text='ï', uppertext='Ï'),
+ Key('ETH', 'Ð', text='ð', uppertext='Ð'),
+ Key('Ntilde', 'Ñ', text='ñ', uppertext='Ñ'),
+ Key('Ograve', 'Ò', text='ò', uppertext='Ò'),
+ Key('Oacute', 'Ó', text='ó', uppertext='Ó'),
+ Key('Ocircumflex', 'Ô', text='ô', uppertext='Ô'),
+ Key('Otilde', 'Õ', text='õ', uppertext='Õ'),
+ Key('Odiaeresis', 'Ö', text='ö', uppertext='Ö'),
+ Key('multiply', '×', text='×', uppertext='×'),
+ Key('Ooblique', 'Ø', text='ø', uppertext='Ø'),
+ Key('Ugrave', 'Ù', text='ù', uppertext='Ù'),
+ Key('Uacute', 'Ú', text='ú', uppertext='Ú'),
+ Key('Ucircumflex', 'Û', text='û', uppertext='Û'),
+ Key('Udiaeresis', 'Ü', text='ü', uppertext='Ü'),
+ Key('Yacute', 'Ý', text='ý', uppertext='Ý'),
+ Key('THORN', 'Þ', text='þ', uppertext='Þ'),
+ Key('ssharp', 'ß', text='ß', uppertext='ß'),
+ Key('division', '÷', text='÷', uppertext='÷'),
+ Key('ydiaeresis', 'Ÿ', text='ÿ', uppertext='Ÿ'),
+
+ ### International input method support (X keycode - 0xEE00, the
+ ### definition follows Qt/Embedded 2.3.7) Only interesting if
+ ### you are writing your own input method
+
+ ### International & multi-key character composition
+ Key('AltGr', qtest=False),
+ Key('Multi_key', 'Multi key', qtest=False), # Multi-key character compose
+ Key('Codeinput', 'Code input', qtest=False),
+ Key('SingleCandidate', 'Single Candidate', qtest=False),
+ Key('MultipleCandidate', 'Multiple Candidate', qtest=False),
+ Key('PreviousCandidate', 'Previous Candidate', qtest=False),
+
+ ### Misc Functions
+ Key('Mode_switch', 'Mode switch', qtest=False), # Character set switch
+ # Key('script_switch'), # Alias for mode_switch
+
+ ### Japanese keyboard support
+ Key('Kanji', qtest=False), # Kanji, Kanji convert
+ Key('Muhenkan', qtest=False), # Cancel Conversion
+ # Key('Henkan_Mode', qtest=False), # Start/Stop Conversion
+ Key('Henkan', qtest=False), # Alias for Henkan_Mode
+ Key('Romaji', qtest=False), # to Romaji
+ Key('Hiragana', qtest=False), # to Hiragana
+ Key('Katakana', qtest=False), # to Katakana
+ Key('Hiragana_Katakana', 'Hiragana Katakana', qtest=False), # Hiragana/Katakana toggle
+ Key('Zenkaku', qtest=False), # to Zenkaku
+ Key('Hankaku', qtest=False), # to Hankaku
+ Key('Zenkaku_Hankaku', 'Zenkaku Hankaku', qtest=False), # Zenkaku/Hankaku toggle
+ Key('Touroku', qtest=False), # Add to Dictionary
+ Key('Massyo', qtest=False), # Delete from Dictionary
+ Key('Kana_Lock', 'Kana Lock', qtest=False),
+ Key('Kana_Shift', 'Kana Shift', qtest=False),
+ Key('Eisu_Shift', 'Eisu Shift', qtest=False), # Alphanumeric Shift
+ Key('Eisu_toggle', 'Eisu toggle', qtest=False), # Alphanumeric toggle
+ # Key('Kanji_Bangou', qtest=False), # Codeinput
+ # Key('Zen_Koho', qtest=False), # Multiple/All Candidate(s)
+ # Key('Mae_Koho', qtest=False), # Previous Candidate
+
+ ### Korean keyboard support
+ ###
+ ### In fact, many users from Korea need only 2 keys, Key_Hangul and
+ ### Key_Hangul_Hanja. But rest of the keys are good for future.
+
+ Key('Hangul', qtest=False), # Hangul start/stop(toggle),
+ Key('Hangul_Start', 'Hangul Start', qtest=False), # Hangul start
+ Key('Hangul_End', 'Hangul End', qtest=False), # Hangul end, English start
+ Key('Hangul_Hanja', 'Hangul Hanja', qtest=False), # Start Hangul->Hanja Conversion
+ Key('Hangul_Jamo', 'Hangul Jamo', qtest=False), # Hangul Jamo mode
+ Key('Hangul_Romaja', 'Hangul Romaja', qtest=False), # Hangul Romaja mode
+ # Key('Hangul_Codeinput', 'Hangul Codeinput', qtest=False),# Hangul code input mode
+ Key('Hangul_Jeonja', 'Hangul Jeonja', qtest=False), # Jeonja mode
+ Key('Hangul_Banja', 'Hangul Banja', qtest=False), # Banja mode
+ Key('Hangul_PreHanja', 'Hangul PreHanja', qtest=False), # Pre Hanja conversion
+ Key('Hangul_PostHanja', 'Hangul PostHanja', qtest=False), # Post Hanja conversion
+ # Key('Hangul_SingleCandidate', 'Hangul SingleCandidate', qtest=False), # Single candidate
+ # Key('Hangul_MultipleCandidate', 'Hangul MultipleCandidate', qtest=False), # Multiple candidate
+ # Key('Hangul_PreviousCandidate', 'Hangul PreviousCandidate', qtest=False), # Previous candidate
+ Key('Hangul_Special', 'Hangul Special', qtest=False), # Special symbols
+ # Key('Hangul_switch', 'Hangul switch', qtest=False), # Alias for mode_switch
+
+ # dead keys (X keycode - 0xED00 to avoid the conflict, qtest=False),
+ Key('Dead_Grave', '`', qtest=False),
+ Key('Dead_Acute', '´', qtest=False),
+ Key('Dead_Circumflex', '^', qtest=False),
+ Key('Dead_Tilde', '~', qtest=False),
+ Key('Dead_Macron', '¯', qtest=False),
+ Key('Dead_Breve', '˘', qtest=False),
+ Key('Dead_Abovedot', '˙', qtest=False),
+ Key('Dead_Diaeresis', '¨', qtest=False),
+ Key('Dead_Abovering', '˚', qtest=False),
+ Key('Dead_Doubleacute', '˝', qtest=False),
+ Key('Dead_Caron', 'ˇ', qtest=False),
+ Key('Dead_Cedilla', '¸', qtest=False),
+ Key('Dead_Ogonek', '˛', qtest=False),
+ Key('Dead_Iota', 'Iota', qtest=False),
+ Key('Dead_Voiced_Sound', 'Voiced Sound', qtest=False),
+ Key('Dead_Semivoiced_Sound', 'Semivoiced Sound', qtest=False),
+ Key('Dead_Belowdot', 'Belowdot', qtest=False),
+ Key('Dead_Hook', 'Hook', qtest=False),
+ Key('Dead_Horn', 'Horn', qtest=False),
+
+ # Not in Qt 5.10, so data may be wrong!
+ Key('Dead_Stroke', qtest=False),
+ Key('Dead_Abovecomma', qtest=False),
+ Key('Dead_Abovereversedcomma', qtest=False),
+ Key('Dead_Doublegrave', qtest=False),
+ Key('Dead_Belowring', qtest=False),
+ Key('Dead_Belowmacron', qtest=False),
+ Key('Dead_Belowcircumflex', qtest=False),
+ Key('Dead_Belowtilde', qtest=False),
+ Key('Dead_Belowbreve', qtest=False),
+ Key('Dead_Belowdiaeresis', qtest=False),
+ Key('Dead_Invertedbreve', qtest=False),
+ Key('Dead_Belowcomma', qtest=False),
+ Key('Dead_Currency', qtest=False),
+ Key('Dead_a', qtest=False),
+ Key('Dead_A', qtest=False),
+ Key('Dead_e', qtest=False),
+ Key('Dead_E', qtest=False),
+ Key('Dead_i', qtest=False),
+ Key('Dead_I', qtest=False),
+ Key('Dead_o', qtest=False),
+ Key('Dead_O', qtest=False),
+ Key('Dead_u', qtest=False),
+ Key('Dead_U', qtest=False),
+ Key('Dead_Small_Schwa', qtest=False),
+ Key('Dead_Capital_Schwa', qtest=False),
+ Key('Dead_Greek', qtest=False),
+ Key('Dead_Lowline', qtest=False),
+ Key('Dead_Aboveverticalline', qtest=False),
+ Key('Dead_Belowverticalline', qtest=False),
+ Key('Dead_Longsolidusoverlay', qtest=False),
+
+ ### multimedia/internet keys - ignored by default - see QKeyEvent c'tor
+ Key('Back'),
+ Key('Forward'),
+ Key('Stop'),
+ Key('Refresh'),
+ Key('VolumeDown', 'Volume Down'),
+ Key('VolumeMute', 'Volume Mute'),
+ Key('VolumeUp', 'Volume Up'),
+ Key('BassBoost', 'Bass Boost'),
+ Key('BassUp', 'Bass Up'),
+ Key('BassDown', 'Bass Down'),
+ Key('TrebleUp', 'Treble Up'),
+ Key('TrebleDown', 'Treble Down'),
+ Key('MediaPlay', 'Media Play'),
+ Key('MediaStop', 'Media Stop'),
+ Key('MediaPrevious', 'Media Previous'),
+ Key('MediaNext', 'Media Next'),
+ Key('MediaRecord', 'Media Record'),
+ Key('MediaPause', 'Media Pause', qtest=False),
+ Key('MediaTogglePlayPause', 'Toggle Media Play/Pause', qtest=False),
+ Key('HomePage', 'Home Page'),
+ Key('Favorites'),
+ Key('Search'),
+ Key('Standby'),
+
+ Key('OpenUrl', 'Open URL'),
+ Key('LaunchMail', 'Launch Mail'),
+ Key('LaunchMedia', 'Launch Media'),
+ Key('Launch0', 'Launch (0)'),
+ Key('Launch1', 'Launch (1)'),
+ Key('Launch2', 'Launch (2)'),
+ Key('Launch3', 'Launch (3)'),
+ Key('Launch4', 'Launch (4)'),
+ Key('Launch5', 'Launch (5)'),
+ Key('Launch6', 'Launch (6)'),
+ Key('Launch7', 'Launch (7)'),
+ Key('Launch8', 'Launch (8)'),
+ Key('Launch9', 'Launch (9)'),
+ Key('LaunchA', 'Launch (A)'),
+ Key('LaunchB', 'Launch (B)'),
+ Key('LaunchC', 'Launch (C)'),
+ Key('LaunchD', 'Launch (D)'),
+ Key('LaunchE', 'Launch (E)'),
+ Key('LaunchF', 'Launch (F)'),
+ Key('MonBrightnessUp', 'Monitor Brightness Up', qtest=False),
+ Key('MonBrightnessDown', 'Monitor Brightness Down', qtest=False),
+ Key('KeyboardLightOnOff', 'Keyboard Light On/Off', qtest=False),
+ Key('KeyboardBrightnessUp', 'Keyboard Brightness Up', qtest=False),
+ Key('KeyboardBrightnessDown', 'Keyboard Brightness Down', qtest=False),
+ Key('PowerOff', 'Power Off', qtest=False),
+ Key('WakeUp', 'Wake Up', qtest=False),
+ Key('Eject', qtest=False),
+ Key('ScreenSaver', 'Screensaver', qtest=False),
+ Key('WWW', qtest=False),
+ Key('Memo', 'Memo', qtest=False),
+ Key('LightBulb', qtest=False),
+ Key('Shop', qtest=False),
+ Key('History', qtest=False),
+ Key('AddFavorite', 'Add Favorite', qtest=False),
+ Key('HotLinks', 'Hot Links', qtest=False),
+ Key('BrightnessAdjust', 'Adjust Brightness', qtest=False),
+ Key('Finance', qtest=False),
+ Key('Community', qtest=False),
+ Key('AudioRewind', 'Media Rewind', qtest=False),
+ Key('BackForward', 'Back Forward', qtest=False),
+ Key('ApplicationLeft', 'Application Left', qtest=False),
+ Key('ApplicationRight', 'Application Right', qtest=False),
+ Key('Book', qtest=False),
+ Key('CD', qtest=False),
+ Key('Calculator', qtest=False),
+ Key('ToDoList', 'To Do List', qtest=False),
+ Key('ClearGrab', 'Clear Grab', qtest=False),
+ Key('Close', qtest=False),
+ Key('Copy', qtest=False),
+ Key('Cut', qtest=False),
+ Key('Display', qtest=False), # Output switch key
+ Key('DOS', qtest=False),
+ Key('Documents', qtest=False),
+ Key('Excel', 'Spreadsheet', qtest=False),
+ Key('Explorer', 'Browser', qtest=False),
+ Key('Game', qtest=False),
+ Key('Go', qtest=False),
+ Key('iTouch', qtest=False),
+ Key('LogOff', 'Logoff', qtest=False),
+ Key('Market', qtest=False),
+ Key('Meeting', qtest=False),
+ Key('MenuKB', 'Keyboard Menu', qtest=False),
+ Key('MenuPB', 'Menu PB', qtest=False),
+ Key('MySites', 'My Sites', qtest=False),
+ Key('News', qtest=False),
+ Key('OfficeHome', 'Home Office', qtest=False),
+ Key('Option', qtest=False),
+ Key('Paste', qtest=False),
+ Key('Phone', qtest=False),
+ Key('Calendar', qtest=False),
+ Key('Reply', qtest=False),
+ Key('Reload', qtest=False),
+ Key('RotateWindows', 'Rotate Windows', qtest=False),
+ Key('RotationPB', 'Rotation PB', qtest=False),
+ Key('RotationKB', 'Rotation KB', qtest=False),
+ Key('Save', qtest=False),
+ Key('Send', qtest=False),
+ Key('Spell', 'Spellchecker', qtest=False),
+ Key('SplitScreen', 'Split Screen', qtest=False),
+ Key('Support', qtest=False),
+ Key('TaskPane', 'Task Panel', qtest=False),
+ Key('Terminal', qtest=False),
+ Key('Tools', qtest=False),
+ Key('Travel', qtest=False),
+ Key('Video', qtest=False),
+ Key('Word', 'Word Processor', qtest=False),
+ Key('Xfer', 'XFer', qtest=False),
+ Key('ZoomIn', 'Zoom In', qtest=False),
+ Key('ZoomOut', 'Zoom Out', qtest=False),
+ Key('Away', qtest=False),
+ Key('Messenger', qtest=False),
+ Key('WebCam', qtest=False),
+ Key('MailForward', 'Mail Forward', qtest=False),
+ Key('Pictures', qtest=False),
+ Key('Music', qtest=False),
+ Key('Battery', qtest=False),
+ Key('Bluetooth', qtest=False),
+ Key('WLAN', 'Wireless', qtest=False),
+ Key('UWB', 'Ultra Wide Band', qtest=False),
+ Key('AudioForward', 'Media Fast Forward', qtest=False),
+ Key('AudioRepeat', 'Audio Repeat', qtest=False), # Toggle repeat mode
+ Key('AudioRandomPlay', 'Audio Random Play', qtest=False), # Toggle shuffle mode
+ Key('Subtitle', qtest=False),
+ Key('AudioCycleTrack', 'Audio Cycle Track', qtest=False),
+ Key('Time', qtest=False),
+ Key('Hibernate', qtest=False),
+ Key('View', qtest=False),
+ Key('TopMenu', 'Top Menu', qtest=False),
+ Key('PowerDown', 'Power Down', qtest=False),
+ Key('Suspend', qtest=False),
+ Key('ContrastAdjust', 'Contrast Adjust', qtest=False),
+
+ Key('LaunchG', 'Launch (G)', qtest=False),
+ Key('LaunchH', 'Launch (H)', qtest=False),
+
+ Key('TouchpadToggle', 'Touchpad Toggle', qtest=False),
+ Key('TouchpadOn', 'Touchpad On', qtest=False),
+ Key('TouchpadOff', 'Touchpad Off', qtest=False),
+
+ Key('MicMute', 'Microphone Mute', qtest=False),
+
+ Key('Red', qtest=False),
+ Key('Green', qtest=False),
+ Key('Yellow', qtest=False),
+ Key('Blue', qtest=False),
+
+ Key('ChannelUp', 'Channel Up', qtest=False),
+ Key('ChannelDown', 'Channel Down', qtest=False),
+
+ Key('Guide', qtest=False),
+ Key('Info', qtest=False),
+ Key('Settings', qtest=False),
+
+ Key('MicVolumeUp', 'Microphone Volume Up', qtest=False),
+ Key('MicVolumeDown', 'Microphone Volume Down', qtest=False),
+
+ Key('New', qtest=False),
+ Key('Open', qtest=False),
+ Key('Find', qtest=False),
+ Key('Undo', qtest=False),
+ Key('Redo', qtest=False),
+
+ Key('MediaLast', 'Media Last', qtest=False),
+
+ ### Keypad navigation keys
+ Key('Select', qtest=False),
+ Key('Yes', qtest=False),
+ Key('No', qtest=False),
+
+ ### Newer misc keys
+ Key('Cancel', qtest=False),
+ Key('Printer', qtest=False),
+ Key('Execute', qtest=False),
+ Key('Sleep', qtest=False),
+ Key('Play', qtest=False), # Not the same as Key_MediaPlay
+ Key('Zoom', qtest=False),
+ # Key('Jisho', qtest=False), # IME: Dictionary key
+ # Key('Oyayubi_Left', qtest=False), # IME: Left Oyayubi key
+ # Key('Oyayubi_Right', qtest=False), # IME: Right Oyayubi key
+ Key('Exit', qtest=False),
+
+ # Device keys
+ Key('Context1', qtest=False),
+ Key('Context2', qtest=False),
+ Key('Context3', qtest=False),
+ Key('Context4', qtest=False),
+ Key('Call', qtest=False), # set absolute state to in a call (do not toggle state)
+ Key('Hangup', qtest=False), # set absolute state to hang up (do not toggle state)
+ Key('Flip', qtest=False),
+ Key('ToggleCallHangup', 'Toggle Call/Hangup', qtest=False), # a toggle key for answering, or hanging up, based on current call state
+ Key('VoiceDial', 'Voice Dial', qtest=False),
+ Key('LastNumberRedial', 'Last Number Redial', qtest=False),
+
+ Key('Camera', 'Camera Shutter', qtest=False),
+ Key('CameraFocus', 'Camera Focus', qtest=False),
+
+ Key('unknown', 'Unknown', qtest=False),
+ # 0x0 is used by Qt for unknown keys...
+ Key(attribute='', name='nil', member=0x0, qtest=False),
+]
+
+
+MODIFIERS = [
+ Modifier('Shift'),
+ Modifier('Control', 'Ctrl'),
+ Modifier('Alt'),
+ Modifier('Meta'),
+ Modifier('Keypad', 'Num'),
+ Modifier('GroupSwitch', 'AltGr'),
+]
diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py
index 423076bdd..7915e2b75 100644
--- a/tests/unit/keyinput/test_basekeyparser.py
+++ b/tests/unit/keyinput/test_basekeyparser.py
@@ -19,51 +19,41 @@
"""Tests for BaseKeyParser."""
-import logging
from unittest import mock
from PyQt5.QtCore import Qt
import pytest
-from qutebrowser.keyinput import basekeyparser
+from qutebrowser.keyinput import basekeyparser, keyutils
from qutebrowser.utils import utils
+# Alias because we need this a lot in here.
+def keyseq(s):
+ return keyutils.KeySequence.parse(s)
+
+
@pytest.fixture
def keyparser(key_config_stub):
"""Fixture providing a BaseKeyParser supporting count/chains."""
- kp = basekeyparser.BaseKeyParser(
- 0, supports_count=True, supports_chains=True)
+ kp = basekeyparser.BaseKeyParser(0, supports_count=True)
kp.execute = mock.Mock()
yield kp
@pytest.fixture
-def handle_text(fake_keyevent_factory, keyparser):
+def handle_text(fake_keyevent, keyparser):
"""Helper function to handle multiple fake keypresses.
Automatically uses the keyparser of the current test via the keyparser
fixture.
"""
def func(*args):
- for enumval, text in args:
- keyparser.handle(fake_keyevent_factory(enumval, text=text))
+ for enumval in args:
+ keyparser.handle(fake_keyevent(enumval))
return func
-@pytest.mark.parametrize('count, chains, count_expected, chains_expected', [
- (True, False, True, False),
- (False, True, False, True),
- (None, True, True, True),
-])
-def test_supports_args(config_stub, count, chains, count_expected,
- chains_expected):
- kp = basekeyparser.BaseKeyParser(
- 0, supports_count=count, supports_chains=chains)
- assert kp._supports_count == count_expected
- assert kp._supports_chains == chains_expected
-
-
class TestDebugLog:
"""Make sure _debug_log only logs when do_log is set."""
@@ -80,18 +70,25 @@ class TestDebugLog:
assert not caplog.records
-@pytest.mark.parametrize('input_key, supports_count, expected', [
+@pytest.mark.parametrize('input_key, supports_count, count, command', [
# (input_key, supports_count, expected)
- ('10', True, (10, '')),
- ('10foo', True, (10, 'foo')),
- ('-1foo', True, (None, '-1foo')),
- ('10e4foo', True, (10, 'e4foo')),
- ('foo', True, (None, 'foo')),
- ('10foo', False, (None, '10foo')),
+ ('10', True, '10', ''),
+ ('10g', True, '10', 'g'),
+ ('10e4g', True, '4', 'g'),
+ ('g', True, '', 'g'),
+ ('0', True, '', ''),
+ ('10g', False, '', 'g'),
])
-def test_split_count(config_stub, input_key, supports_count, expected):
+def test_split_count(config_stub, key_config_stub,
+ input_key, supports_count, count, command):
kp = basekeyparser.BaseKeyParser(0, supports_count=supports_count)
- assert kp._split_count(input_key) == expected
+ kp._read_config('normal')
+
+ for info in keyseq(input_key):
+ kp.handle(info.to_event())
+
+ assert kp._count == count
+ assert kp._sequence == keyseq(command)
@pytest.mark.usefixtures('keyinput_bindings')
@@ -106,18 +103,24 @@ class TestReadConfig:
"""Test reading config with _modename set."""
keyparser._modename = 'normal'
keyparser._read_config()
- assert 'a' in keyparser.bindings
+ assert keyseq('a') in keyparser.bindings
def test_read_config_valid(self, keyparser):
"""Test reading config."""
keyparser._read_config('prompt')
- assert 'ccc' in keyparser.bindings
- assert 'ctrl+a' in keyparser.special_bindings
+ assert keyseq('ccc') in keyparser.bindings
+ assert keyseq('<ctrl+a>') in keyparser.bindings
keyparser._read_config('command')
- assert 'ccc' not in keyparser.bindings
- assert 'ctrl+a' not in keyparser.special_bindings
- assert 'foo' in keyparser.bindings
- assert 'ctrl+x' in keyparser.special_bindings
+ assert keyseq('ccc') not in keyparser.bindings
+ assert keyseq('<ctrl+a>') not in keyparser.bindings
+ assert keyseq('foo') in keyparser.bindings
+ assert keyseq('<ctrl+x>') in keyparser.bindings
+
+ def test_read_config_empty_binding(self, keyparser, config_stub):
+ """Make sure setting an empty binding doesn't crash."""
+ keyparser._read_config('normal')
+ config_stub.val.bindings.commands = {'normal': {'co': ''}}
+ # The config is re-read automatically
def test_read_config_modename_none(self, keyparser):
assert keyparser._modename is None
@@ -134,140 +137,152 @@ class TestReadConfig:
mode, changed_mode, expected):
keyparser._read_config(mode)
# Sanity checks
- assert 'a' in keyparser.bindings
- assert 'new' not in keyparser.bindings
-
- key_config_stub.bind('new', 'message-info new', mode=changed_mode)
-
- assert 'a' in keyparser.bindings
- assert ('new' in keyparser.bindings) == expected
+ assert keyseq('a') in keyparser.bindings
+ assert keyseq('new') not in keyparser.bindings
- @pytest.mark.parametrize('warn_on_keychains', [True, False])
- def test_warn_on_keychains(self, caplog, warn_on_keychains):
- """Test _warn_on_keychains."""
- kp = basekeyparser.BaseKeyParser(
- 0, supports_count=False, supports_chains=False)
- kp._warn_on_keychains = warn_on_keychains
+ key_config_stub.bind(keyseq('new'), 'message-info new',
+ mode=changed_mode)
- with caplog.at_level(logging.WARNING):
- kp._read_config('normal')
+ assert keyseq('a') in keyparser.bindings
+ assert (keyseq('new') in keyparser.bindings) == expected
- assert bool(caplog.records) == warn_on_keychains
-
-class TestSpecialKeys:
-
- """Check execute() with special keys."""
+class TestHandle:
@pytest.fixture(autouse=True)
def read_config(self, keyinput_bindings, keyparser):
keyparser._read_config('prompt')
- def test_valid_key(self, fake_keyevent_factory, keyparser):
+ def test_valid_key(self, fake_keyevent, keyparser):
modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier
- keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier))
- keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier))
- keyparser.execute.assert_called_once_with(
- 'message-info ctrla', keyparser.Type.special, None)
+ keyparser.handle(fake_keyevent(Qt.Key_A, modifier))
+ keyparser.handle(fake_keyevent(Qt.Key_X, modifier))
+ keyparser.execute.assert_called_once_with('message-info ctrla', None)
+ assert not keyparser._sequence
- def test_valid_key_count(self, fake_keyevent_factory, keyparser):
+ def test_valid_key_count(self, fake_keyevent, keyparser):
modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier
- keyparser.handle(fake_keyevent_factory(5, text='5'))
- keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier, text='A'))
- keyparser.execute.assert_called_once_with(
- 'message-info ctrla', keyparser.Type.special, 5)
-
- def test_invalid_key(self, fake_keyevent_factory, keyparser):
- keyparser.handle(fake_keyevent_factory(
- Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier)))
+ keyparser.handle(fake_keyevent(Qt.Key_5))
+ keyparser.handle(fake_keyevent(Qt.Key_A, modifier))
+ keyparser.execute.assert_called_once_with('message-info ctrla', 5)
+
+ @pytest.mark.parametrize('keys', [
+ [(Qt.Key_B, Qt.NoModifier), (Qt.Key_C, Qt.NoModifier)],
+ [(Qt.Key_A, Qt.ControlModifier | Qt.AltModifier)],
+ # Only modifier
+ [(Qt.Key_Shift, Qt.ShiftModifier)],
+ ])
+ def test_invalid_keys(self, fake_keyevent, keyparser, keys):
+ for key, modifiers in keys:
+ keyparser.handle(fake_keyevent(key, modifiers))
assert not keyparser.execute.called
+ assert not keyparser._sequence
- def test_keychain(self, fake_keyevent_factory, keyparser):
- keyparser.handle(fake_keyevent_factory(Qt.Key_B))
- keyparser.handle(fake_keyevent_factory(Qt.Key_A))
+ def test_dry_run(self, fake_keyevent, keyparser):
+ keyparser.handle(fake_keyevent(Qt.Key_B))
+ keyparser.handle(fake_keyevent(Qt.Key_A), dry_run=True)
assert not keyparser.execute.called
+ assert keyparser._sequence
- def test_no_binding(self, monkeypatch, fake_keyevent_factory, keyparser):
- monkeypatch.setattr(utils, 'keyevent_to_string', lambda binding: None)
- keyparser.handle(fake_keyevent_factory(Qt.Key_A, Qt.NoModifier))
- assert not keyparser.execute.called
+ def test_dry_run_count(self, fake_keyevent, keyparser):
+ keyparser.handle(fake_keyevent(Qt.Key_9), dry_run=True)
+ assert not keyparser._count
- def test_mapping(self, config_stub, fake_keyevent_factory, keyparser):
- modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier
-
- keyparser.handle(fake_keyevent_factory(Qt.Key_B, modifier))
- keyparser.execute.assert_called_once_with(
- 'message-info ctrla', keyparser.Type.special, None)
-
- def test_binding_and_mapping(self, config_stub, fake_keyevent_factory,
- keyparser):
- """with a conflicting binding/mapping, the binding should win."""
- modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier
-
- keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier))
- keyparser.execute.assert_called_once_with(
- 'message-info ctrla', keyparser.Type.special, None)
-
-
-class TestKeyChain:
-
- """Test execute() with keychain support."""
-
- @pytest.fixture(autouse=True)
- def read_config(self, keyinput_bindings, keyparser):
- keyparser._read_config('prompt')
-
- def test_valid_special_key(self, fake_keyevent_factory, keyparser):
- if utils.is_mac:
- modifier = Qt.MetaModifier
- else:
- modifier = Qt.ControlModifier
- keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier))
- keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier))
- keyparser.execute.assert_called_once_with(
- 'message-info ctrla', keyparser.Type.special, None)
- assert keyparser._keystring == ''
-
- def test_invalid_special_key(self, fake_keyevent_factory, keyparser):
- keyparser.handle(fake_keyevent_factory(
- Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier)))
- assert not keyparser.execute.called
- assert keyparser._keystring == ''
+ def test_invalid_key(self, fake_keyevent, keyparser):
+ keyparser.handle(fake_keyevent(Qt.Key_B))
+ keyparser.handle(fake_keyevent(0x0))
+ assert not keyparser._sequence
def test_valid_keychain(self, handle_text, keyparser):
# Press 'x' which is ignored because of no match
- handle_text((Qt.Key_X, 'x'),
+ handle_text(Qt.Key_X,
# Then start the real chain
- (Qt.Key_B, 'b'), (Qt.Key_A, 'a'))
- keyparser.execute.assert_called_with(
- 'message-info ba', keyparser.Type.chain, None)
- assert keyparser._keystring == ''
-
- def test_0_press(self, handle_text, keyparser):
- handle_text((Qt.Key_0, '0'))
- keyparser.execute.assert_called_once_with(
- 'message-info 0', keyparser.Type.chain, None)
- assert keyparser._keystring == ''
-
- def test_ambiguous_keychain(self, handle_text, keyparser):
- handle_text((Qt.Key_A, 'a'))
- assert keyparser.execute.called
-
- def test_invalid_keychain(self, handle_text, keyparser):
- handle_text((Qt.Key_B, 'b'))
- handle_text((Qt.Key_C, 'c'))
- assert keyparser._keystring == ''
+ Qt.Key_B, Qt.Key_A)
+ keyparser.execute.assert_called_with('message-info ba', None)
+ assert not keyparser._sequence
+
+ @pytest.mark.parametrize('key, modifiers, number', [
+ (Qt.Key_0, Qt.NoModifier, 0),
+ (Qt.Key_1, Qt.NoModifier, 1),
+ (Qt.Key_1, Qt.KeypadModifier, 1),
+ ])
+ def test_number_press(self, fake_keyevent, keyparser,
+ key, modifiers, number):
+ keyparser.handle(fake_keyevent(key, modifiers))
+ command = 'message-info {}'.format(number)
+ keyparser.execute.assert_called_once_with(command, None)
+ assert not keyparser._sequence
+
+ @pytest.mark.parametrize('modifiers, text', [
+ (Qt.NoModifier, '2'),
+ (Qt.KeypadModifier, 'num-2'),
+ ])
+ def test_number_press_keypad(self, fake_keyevent, keyparser, config_stub,
+ modifiers, text):
+ """Make sure a <Num+2> binding overrides the 2 binding."""
+ config_stub.val.bindings.commands = {'normal': {
+ '2': 'message-info 2',
+ '<Num+2>': 'message-info num-2'}}
+ keyparser._read_config('normal')
+ keyparser.handle(fake_keyevent(Qt.Key_2, modifiers))
+ command = 'message-info {}'.format(text)
+ keyparser.execute.assert_called_once_with(command, None)
+ assert not keyparser._sequence
+
+ def test_umlauts(self, handle_text, keyparser, config_stub):
+ config_stub.val.bindings.commands = {'normal': {'ü': 'message-info ü'}}
+ keyparser._read_config('normal')
+ handle_text(Qt.Key_Udiaeresis)
+ keyparser.execute.assert_called_once_with('message-info ü', None)
def test_mapping(self, config_stub, handle_text, keyparser):
- handle_text((Qt.Key_X, 'x'))
- keyparser.execute.assert_called_once_with(
- 'message-info a', keyparser.Type.chain, None)
+ handle_text(Qt.Key_X)
+ keyparser.execute.assert_called_once_with('message-info a', None)
+
+ def test_mapping_keypad(self, config_stub, fake_keyevent, keyparser):
+ """Make sure falling back to non-numpad keys works with mappings."""
+ config_stub.val.bindings.commands = {'normal': {'a': 'nop'}}
+ config_stub.val.bindings.key_mappings = {'1': 'a'}
+ keyparser._read_config('normal')
+
+ keyparser.handle(fake_keyevent(Qt.Key_1, Qt.KeypadModifier))
+ keyparser.execute.assert_called_once_with('nop', None)
def test_binding_and_mapping(self, config_stub, handle_text, keyparser):
"""with a conflicting binding/mapping, the binding should win."""
- handle_text((Qt.Key_B, 'b'))
+ handle_text(Qt.Key_B)
assert not keyparser.execute.called
+ def test_mapping_in_key_chain(self, config_stub, handle_text, keyparser):
+ """A mapping should work even as part of a keychain."""
+ config_stub.val.bindings.commands = {'normal':
+ {'aa': 'message-info aa'}}
+ keyparser._read_config('normal')
+ handle_text(Qt.Key_A, Qt.Key_X)
+ keyparser.execute.assert_called_once_with('message-info aa', None)
+
+ def test_binding_with_shift(self, keyparser, fake_keyevent):
+ """Simulate a binding which involves shift."""
+ for key, modifiers in [(Qt.Key_Y, Qt.NoModifier),
+ (Qt.Key_Shift, Qt.ShiftModifier),
+ (Qt.Key_Y, Qt.ShiftModifier)]:
+ keyparser.handle(fake_keyevent(key, modifiers))
+
+ keyparser.execute.assert_called_once_with('yank -s', None)
+
+ def test_partial_before_full_match(self, keyparser, fake_keyevent,
+ config_stub):
+ """Make sure full matches always take precedence over partial ones."""
+ config_stub.val.bindings.commands = {
+ 'normal': {
+ 'ab': 'message-info bar',
+ 'a': 'message-info foo'
+ }
+ }
+ keyparser._read_config('normal')
+ keyparser.handle(fake_keyevent(Qt.Key_A))
+ keyparser.execute.assert_called_once_with('message-info foo', None)
+
class TestCount:
@@ -279,42 +294,63 @@ class TestCount:
def test_no_count(self, handle_text, keyparser):
"""Test with no count added."""
- handle_text((Qt.Key_B, 'b'), (Qt.Key_A, 'a'))
- keyparser.execute.assert_called_once_with(
- 'message-info ba', keyparser.Type.chain, None)
- assert keyparser._keystring == ''
+ handle_text(Qt.Key_B, Qt.Key_A)
+ keyparser.execute.assert_called_once_with('message-info ba', None)
+ assert not keyparser._sequence
def test_count_0(self, handle_text, keyparser):
- handle_text((Qt.Key_0, '0'), (Qt.Key_B, 'b'), (Qt.Key_A, 'a'))
- calls = [mock.call('message-info 0', keyparser.Type.chain, None),
- mock.call('message-info ba', keyparser.Type.chain, None)]
+ handle_text(Qt.Key_0, Qt.Key_B, Qt.Key_A)
+ calls = [mock.call('message-info 0', None),
+ mock.call('message-info ba', None)]
keyparser.execute.assert_has_calls(calls)
- assert keyparser._keystring == ''
+ assert not keyparser._sequence
def test_count_42(self, handle_text, keyparser):
- handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_B, 'b'),
- (Qt.Key_A, 'a'))
- keyparser.execute.assert_called_once_with(
- 'message-info ba', keyparser.Type.chain, 42)
- assert keyparser._keystring == ''
+ handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_B, Qt.Key_A)
+ keyparser.execute.assert_called_once_with('message-info ba', 42)
+ assert not keyparser._sequence
def test_count_42_invalid(self, handle_text, keyparser):
# Invalid call with ccx gets ignored
- handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_C, 'c'),
- (Qt.Key_C, 'c'), (Qt.Key_X, 'x'))
+ handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_C, Qt.Key_C, Qt.Key_X)
assert not keyparser.execute.called
- assert keyparser._keystring == ''
+ assert not keyparser._sequence
# Valid call with ccc gets the correct count
- handle_text((Qt.Key_6, '2'), (Qt.Key_2, '3'), (Qt.Key_C, 'c'),
- (Qt.Key_C, 'c'), (Qt.Key_C, 'c'))
- keyparser.execute.assert_called_once_with(
- 'message-info ccc', keyparser.Type.chain, 23)
- assert keyparser._keystring == ''
+ handle_text(Qt.Key_2, Qt.Key_3, Qt.Key_C, Qt.Key_C, Qt.Key_C)
+ keyparser.execute.assert_called_once_with('message-info ccc', 23)
+ assert not keyparser._sequence
+
+ def test_count_keystring_update(self, qtbot, handle_text, keyparser):
+ """Make sure the keystring is updated correctly when entering count."""
+ with qtbot.waitSignals([keyparser.keystring_updated,
+ keyparser.keystring_updated]) as blocker:
+ handle_text(Qt.Key_4, Qt.Key_2)
+ sig1, sig2 = blocker.all_signals_and_args
+ assert sig1.args == ('4',)
+ assert sig2.args == ('42',)
+
+ def test_numpad(self, fake_keyevent, keyparser):
+ """Make sure we can enter a count via numpad."""
+ for key, modifiers in [(Qt.Key_4, Qt.KeypadModifier),
+ (Qt.Key_2, Qt.KeypadModifier),
+ (Qt.Key_B, Qt.NoModifier),
+ (Qt.Key_A, Qt.NoModifier)]:
+ keyparser.handle(fake_keyevent(key, modifiers))
+ keyparser.execute.assert_called_once_with('message-info ba', 42)
def test_clear_keystring(qtbot, keyparser):
"""Test that the keystring is cleared and the signal is emitted."""
- keyparser._keystring = 'test'
+ keyparser._sequence = keyseq('test')
+ keyparser._count = '23'
with qtbot.waitSignal(keyparser.keystring_updated):
keyparser.clear_keystring()
- assert keyparser._keystring == ''
+ assert not keyparser._sequence
+ assert not keyparser._count
+
+
+def test_clear_keystring_empty(qtbot, keyparser):
+ """Test that no signal is emitted when clearing an empty keystring.."""
+ keyparser._sequence = keyseq('')
+ with qtbot.assert_not_emitted(keyparser.keystring_updated):
+ keyparser.clear_keystring()
diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py
new file mode 100644
index 000000000..37519ca85
--- /dev/null
+++ b/tests/unit/keyinput/test_keyutils.py
@@ -0,0 +1,545 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+import operator
+
+import hypothesis
+from hypothesis import strategies
+import pytest
+from PyQt5.QtCore import Qt, QEvent, pyqtSignal
+from PyQt5.QtGui import QKeyEvent, QKeySequence
+from PyQt5.QtWidgets import QWidget
+
+from tests.unit.keyinput import key_data
+from qutebrowser.keyinput import keyutils
+from qutebrowser.utils import utils
+
+
+@pytest.fixture(params=key_data.KEYS, ids=lambda k: k.attribute)
+def qt_key(request):
+ """Get all existing keys from key_data.py.
+
+ Keys which don't exist with this Qt version result in skipped tests.
+ """
+ key = request.param
+ if key.member is None:
+ pytest.skip("Did not find key {}".format(key.attribute))
+ return key
+
+
+@pytest.fixture(params=key_data.MODIFIERS, ids=lambda m: m.attribute)
+def qt_mod(request):
+ """Get all existing modifiers from key_data.py."""
+ mod = request.param
+ assert mod.member is not None
+ return mod
+
+
+@pytest.fixture(params=[key for key in key_data.KEYS if key.qtest],
+ ids=lambda k: k.attribute)
+def qtest_key(request):
+ """Get keys from key_data.py which can be used with QTest."""
+ return request.param
+
+
+def test_key_data_keys():
+ """Make sure all possible keys are in key_data.KEYS."""
+ key_names = {name[len("Key_"):]
+ for name, value in sorted(vars(Qt).items())
+ if isinstance(value, Qt.Key)}
+ key_data_names = {key.attribute for key in sorted(key_data.KEYS)}
+ diff = key_names - key_data_names
+ assert not diff
+
+
+def test_key_data_modifiers():
+ """Make sure all possible modifiers are in key_data.MODIFIERS."""
+ mod_names = {name[:-len("Modifier")]
+ for name, value in sorted(vars(Qt).items())
+ if isinstance(value, Qt.KeyboardModifier) and
+ value not in [Qt.NoModifier, Qt.KeyboardModifierMask]}
+ mod_data_names = {mod.attribute for mod in sorted(key_data.MODIFIERS)}
+ diff = mod_names - mod_data_names
+ assert not diff
+
+
+class KeyTesterWidget(QWidget):
+
+ """Widget to get the text of QKeyPressEvents.
+
+ This is done so we can check QTest::keyToAscii (qasciikey.cpp) as we can't
+ call that directly, only via QTest::keyPress.
+ """
+
+ got_text = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.text = None
+
+ def keyPressEvent(self, e):
+ self.text = e.text()
+ self.got_text.emit()
+
+
+class TestKeyInfoText:
+
+ @pytest.mark.parametrize('upper', [False, True])
+ def test_text(self, qt_key, upper):
+ """Test KeyInfo.text() with all possible keys.
+
+ See key_data.py for inputs and expected values.
+ """
+ modifiers = Qt.ShiftModifier if upper else Qt.KeyboardModifiers()
+ info = keyutils.KeyInfo(qt_key.member, modifiers=modifiers)
+ expected = qt_key.uppertext if upper else qt_key.text
+ assert info.text() == expected
+
+ @pytest.fixture
+ def key_tester(self, qtbot):
+ w = KeyTesterWidget()
+ qtbot.add_widget(w)
+ return w
+
+ def test_text_qtest(self, qtest_key, qtbot, key_tester):
+ """Make sure KeyInfo.text() lines up with QTest::keyToAscii.
+
+ See key_data.py for inputs and expected values.
+ """
+ with qtbot.wait_signal(key_tester.got_text):
+ qtbot.keyPress(key_tester, qtest_key.member)
+
+ info = keyutils.KeyInfo(qtest_key.member,
+ modifiers=Qt.KeyboardModifiers())
+ assert info.text() == key_tester.text.lower()
+
+
+class TestKeyToString:
+
+ def test_to_string(self, qt_key):
+ assert keyutils._key_to_string(qt_key.member) == qt_key.name
+
+ def test_modifiers_to_string(self, qt_mod):
+ expected = qt_mod.name + '+'
+ assert keyutils._modifiers_to_string(qt_mod.member) == expected
+
+ def test_missing(self, monkeypatch):
+ monkeypatch.delattr(keyutils.Qt, 'Key_AltGr')
+ # We don't want to test the key which is actually missing - we only
+ # want to know if the mapping still behaves properly.
+ assert keyutils._key_to_string(Qt.Key_A) == 'A'
+
+
+@pytest.mark.parametrize('key, modifiers, expected', [
+ (Qt.Key_A, Qt.NoModifier, 'a'),
+ (Qt.Key_A, Qt.ShiftModifier, 'A'),
+
+ (Qt.Key_Space, Qt.NoModifier, '<Space>'),
+ (Qt.Key_Space, Qt.ShiftModifier, '<Shift+Space>'),
+ (Qt.Key_Tab, Qt.ShiftModifier, '<Shift+Tab>'),
+ (Qt.Key_A, Qt.ControlModifier, '<Ctrl+a>'),
+ (Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, '<Ctrl+Shift+a>'),
+ (Qt.Key_A,
+ Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.ShiftModifier,
+ '<Meta+Ctrl+Alt+Shift+a>'),
+
+ (Qt.Key_Shift, Qt.ShiftModifier, '<Shift>'),
+ (Qt.Key_Shift, Qt.ShiftModifier | Qt.ControlModifier, '<Ctrl+Shift>'),
+])
+def test_key_info_str(key, modifiers, expected):
+ assert str(keyutils.KeyInfo(key, modifiers)) == expected
+
+
+@pytest.mark.parametrize('keystr, expected', [
+ ('foo', "Could not parse 'foo': error"),
+ (None, "Could not parse keystring: error"),
+])
+def test_key_parse_error(keystr, expected):
+ exc = keyutils.KeyParseError(keystr, "error")
+ assert str(exc) == expected
+
+
+@pytest.mark.parametrize('keystr, parts', [
+ ('a', ['a']),
+ ('ab', ['a', 'b']),
+ ('a<', ['a', '<']),
+ ('a>', ['a', '>']),
+ ('<a', ['<', 'a']),
+ ('>a', ['>', 'a']),
+ ('aA', ['a', 'Shift+A']),
+ ('a<Ctrl+a>b', ['a', 'ctrl+a', 'b']),
+ ('<Ctrl+a>a', ['ctrl+a', 'a']),
+ ('a<Ctrl+a>', ['a', 'ctrl+a']),
+ ('<Ctrl-a>', ['ctrl+a']),
+ ('<Num-a>', ['num+a']),
+])
+def test_parse_keystr(keystr, parts):
+ assert list(keyutils._parse_keystring(keystr)) == parts
+
+
+class TestKeySequence:
+
+ def test_init(self):
+ seq = keyutils.KeySequence(Qt.Key_A, Qt.Key_B, Qt.Key_C, Qt.Key_D,
+ Qt.Key_E)
+ assert len(seq._sequences) == 2
+ assert len(seq._sequences[0]) == 4
+ assert len(seq._sequences[1]) == 1
+
+ def test_init_empty(self):
+ seq = keyutils.KeySequence()
+ assert not seq
+
+ @pytest.mark.parametrize('key', [Qt.Key_unknown, -1, '\x1f', 0])
+ def test_init_unknown(self, key):
+ with pytest.raises(keyutils.KeyParseError):
+ keyutils.KeySequence(key)
+
+ @pytest.mark.parametrize('orig, normalized', [
+ ('<Control+x>', '<Ctrl+x>'),
+ ('<Windows+x>', '<Meta+x>'),
+ ('<Mod1+x>', '<Alt+x>'),
+ ('<Mod4+x>', '<Meta+x>'),
+ ('<Control-->', '<Ctrl+->'),
+ ('<Windows++>', '<Meta++>'),
+ ('<ctrl-x>', '<Ctrl+x>'),
+ ('<control+x>', '<Ctrl+x>'),
+ ('<a>b', 'ab'),
+ ])
+ def test_str_normalization(self, orig, normalized):
+ assert str(keyutils.KeySequence.parse(orig)) == normalized
+
+ def test_iter(self):
+ seq = keyutils.KeySequence(Qt.Key_A | Qt.ControlModifier,
+ Qt.Key_B | Qt.ShiftModifier,
+ Qt.Key_C,
+ Qt.Key_D,
+ Qt.Key_E)
+ expected = [keyutils.KeyInfo(Qt.Key_A, Qt.ControlModifier),
+ keyutils.KeyInfo(Qt.Key_B, Qt.ShiftModifier),
+ keyutils.KeyInfo(Qt.Key_C, Qt.NoModifier),
+ keyutils.KeyInfo(Qt.Key_D, Qt.NoModifier),
+ keyutils.KeyInfo(Qt.Key_E, Qt.NoModifier)]
+ assert list(seq) == expected
+
+ def test_repr(self):
+ seq = keyutils.KeySequence(Qt.Key_A | Qt.ControlModifier,
+ Qt.Key_B | Qt.ShiftModifier)
+ assert repr(seq) == ("<qutebrowser.keyinput.keyutils.KeySequence "
+ "keys='<Ctrl+a>B'>")
+
+ @pytest.mark.parametrize('sequences, expected', [
+ (['a', ''], ['', 'a']),
+ (['abcdf', 'abcd', 'abcde'], ['abcd', 'abcde', 'abcdf']),
+ ])
+ def test_sorting(self, sequences, expected):
+ result = sorted(keyutils.KeySequence.parse(seq) for seq in sequences)
+ expected_result = [keyutils.KeySequence.parse(seq) for seq in expected]
+ assert result == expected_result
+
+ @pytest.mark.parametrize('seq1, seq2, op, result', [
+ ('a', 'a', operator.eq, True),
+ ('a', '<a>', operator.eq, True),
+ ('a', '<Shift-a>', operator.eq, False),
+ ('a', 'b', operator.lt, True),
+ ('a', 'b', operator.le, True),
+ ])
+ def test_operators(self, seq1, seq2, op, result):
+ seq1 = keyutils.KeySequence.parse(seq1)
+ seq2 = keyutils.KeySequence.parse(seq2)
+ assert op(seq1, seq2) == result
+
+ opposite = {
+ operator.lt: operator.ge,
+ operator.gt: operator.le,
+ operator.le: operator.gt,
+ operator.ge: operator.lt,
+ operator.eq: operator.ne,
+ operator.ne: operator.eq,
+ }
+ assert opposite[op](seq1, seq2) != result
+
+ @pytest.mark.parametrize('seq1, seq2, equal', [
+ ('a', 'a', True),
+ ('a', 'A', False),
+ ('a', '<a>', True),
+ ('abcd', 'abcde', False),
+ ])
+ def test_hash(self, seq1, seq2, equal):
+ seq1 = keyutils.KeySequence.parse(seq1)
+ seq2 = keyutils.KeySequence.parse(seq2)
+ assert (hash(seq1) == hash(seq2)) == equal
+
+ @pytest.mark.parametrize('seq, length', [
+ ('', 0),
+ ('a', 1),
+ ('A', 1),
+ ('<Ctrl-a>', 1),
+ ('abcde', 5)
+ ])
+ def test_len(self, seq, length):
+ assert len(keyutils.KeySequence.parse(seq)) == length
+
+ def test_bool(self):
+ seq1 = keyutils.KeySequence.parse('abcd')
+ seq2 = keyutils.KeySequence()
+ assert seq1
+ assert not seq2
+
+ def test_getitem(self):
+ seq = keyutils.KeySequence.parse('ab')
+ expected = keyutils.KeyInfo(Qt.Key_B, Qt.NoModifier)
+ assert seq[1] == expected
+
+ def test_getitem_slice(self):
+ s1 = 'abcdef'
+ s2 = 'de'
+ seq = keyutils.KeySequence.parse(s1)
+ expected = keyutils.KeySequence.parse(s2)
+ assert s1[3:5] == s2
+ assert seq[3:5] == expected
+
+ @pytest.mark.parametrize('entered, configured, expected', [
+ # config: abcd
+ ('abc', 'abcd', QKeySequence.PartialMatch),
+ ('abcd', 'abcd', QKeySequence.ExactMatch),
+ ('ax', 'abcd', QKeySequence.NoMatch),
+ ('abcdef', 'abcd', QKeySequence.NoMatch),
+
+ # config: abcd ef
+ ('abc', 'abcdef', QKeySequence.PartialMatch),
+ ('abcde', 'abcdef', QKeySequence.PartialMatch),
+ ('abcd', 'abcdef', QKeySequence.PartialMatch),
+ ('abcdx', 'abcdef', QKeySequence.NoMatch),
+ ('ax', 'abcdef', QKeySequence.NoMatch),
+ ('abcdefg', 'abcdef', QKeySequence.NoMatch),
+ ('abcdef', 'abcdef', QKeySequence.ExactMatch),
+
+ # other examples
+ ('ab', 'a', QKeySequence.NoMatch),
+
+ # empty strings
+ ('', '', QKeySequence.ExactMatch),
+ ('', 'a', QKeySequence.PartialMatch),
+ ('a', '', QKeySequence.NoMatch),
+ ])
+ def test_matches(self, entered, configured, expected):
+ entered = keyutils.KeySequence.parse(entered)
+ configured = keyutils.KeySequence.parse(configured)
+ assert entered.matches(configured) == expected
+
+ @pytest.mark.parametrize('old, key, modifiers, text, expected', [
+ ('a', Qt.Key_B, Qt.NoModifier, 'b', 'ab'),
+ ('a', Qt.Key_B, Qt.ShiftModifier, 'B', 'aB'),
+ ('a', Qt.Key_B, Qt.AltModifier | Qt.ShiftModifier, 'B',
+ 'a<Alt+Shift+b>'),
+
+ # Modifier stripping with symbols
+ ('', Qt.Key_Colon, Qt.NoModifier, ':', ':'),
+ ('', Qt.Key_Colon, Qt.ShiftModifier, ':', ':'),
+ ('', Qt.Key_Colon, Qt.AltModifier | Qt.ShiftModifier, ':',
+ '<Alt+Shift+:>'),
+
+ # Swapping Control/Meta on macOS
+ ('', Qt.Key_A, Qt.ControlModifier, '',
+ '<Meta+A>' if utils.is_mac else '<Ctrl+A>'),
+ ('', Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, '',
+ '<Meta+Shift+A>' if utils.is_mac else '<Ctrl+Shift+A>'),
+ ('', Qt.Key_A, Qt.MetaModifier, '',
+ '<Ctrl+A>' if utils.is_mac else '<Meta+A>'),
+
+ # Handling of Backtab
+ ('', Qt.Key_Backtab, Qt.NoModifier, '', '<Backtab>'),
+ ('', Qt.Key_Backtab, Qt.ShiftModifier, '', '<Shift+Tab>'),
+ ('', Qt.Key_Backtab, Qt.AltModifier | Qt.ShiftModifier, '',
+ '<Alt+Shift+Tab>'),
+
+ # Stripping of Qt.GroupSwitchModifier
+ ('', Qt.Key_A, Qt.GroupSwitchModifier, 'a', 'a'),
+ ])
+ def test_append_event(self, old, key, modifiers, text, expected):
+ seq = keyutils.KeySequence.parse(old)
+ event = QKeyEvent(QKeyEvent.KeyPress, key, modifiers, text)
+ new = seq.append_event(event)
+ assert new == keyutils.KeySequence.parse(expected)
+
+ @pytest.mark.fake_os('mac')
+ @pytest.mark.parametrize('modifiers, expected', [
+ (Qt.ControlModifier,
+ Qt.MetaModifier),
+ (Qt.MetaModifier,
+ Qt.ControlModifier),
+ (Qt.ControlModifier | Qt.MetaModifier,
+ Qt.ControlModifier | Qt.MetaModifier),
+ (Qt.ControlModifier | Qt.ShiftModifier,
+ Qt.MetaModifier | Qt.ShiftModifier),
+ (Qt.MetaModifier | Qt.ShiftModifier,
+ Qt.ControlModifier | Qt.ShiftModifier),
+ (Qt.ShiftModifier, Qt.ShiftModifier),
+ ])
+ def test_fake_mac(self, fake_keyevent, modifiers, expected):
+ """Make sure Control/Meta are swapped with a simulated Mac."""
+ seq = keyutils.KeySequence()
+ event = fake_keyevent(key=Qt.Key_A, modifiers=modifiers)
+ new = seq.append_event(event)
+ assert new[0] == keyutils.KeyInfo(Qt.Key_A, expected)
+
+ @pytest.mark.parametrize('key', [Qt.Key_unknown, 0x0])
+ def test_append_event_invalid(self, key):
+ seq = keyutils.KeySequence()
+ event = QKeyEvent(QKeyEvent.KeyPress, key, Qt.NoModifier, '')
+ with pytest.raises(keyutils.KeyParseError):
+ seq.append_event(event)
+
+ def test_strip_modifiers(self):
+ seq = keyutils.KeySequence(Qt.Key_0,
+ Qt.Key_1 | Qt.KeypadModifier,
+ Qt.Key_A | Qt.ControlModifier)
+ expected = keyutils.KeySequence(Qt.Key_0,
+ Qt.Key_1,
+ Qt.Key_A | Qt.ControlModifier)
+ assert seq.strip_modifiers() == expected
+
+ def test_with_mappings(self):
+ seq = keyutils.KeySequence.parse('foobar')
+ mappings = {keyutils.KeySequence('b'): keyutils.KeySequence('t')}
+ seq2 = seq.with_mappings(mappings)
+ assert seq2 == keyutils.KeySequence.parse('footar')
+
+ @pytest.mark.parametrize('keystr, expected', [
+ ('<Ctrl-Alt-y>',
+ keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)),
+ ('x', keyutils.KeySequence(Qt.Key_X)),
+ ('X', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)),
+ ('<Escape>', keyutils.KeySequence(Qt.Key_Escape)),
+ ('xyz', keyutils.KeySequence(Qt.Key_X, Qt.Key_Y, Qt.Key_Z)),
+ ('<Control-x><Meta-y>',
+ keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X,
+ Qt.MetaModifier | Qt.Key_Y)),
+
+ ('<Shift-x>', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)),
+ ('<Alt-x>', keyutils.KeySequence(Qt.AltModifier | Qt.Key_X)),
+ ('<Control-x>', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)),
+ ('<Meta-x>', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)),
+ ('<Num-x>', keyutils.KeySequence(Qt.KeypadModifier | Qt.Key_X)),
+
+ ('>', keyutils.KeySequence(Qt.Key_Greater)),
+ ('<', keyutils.KeySequence(Qt.Key_Less)),
+ ('a>', keyutils.KeySequence(Qt.Key_A, Qt.Key_Greater)),
+ ('a<', keyutils.KeySequence(Qt.Key_A, Qt.Key_Less)),
+ ('>a', keyutils.KeySequence(Qt.Key_Greater, Qt.Key_A)),
+ ('<a', keyutils.KeySequence(Qt.Key_Less, Qt.Key_A)),
+ ('<alt+greater>',
+ keyutils.KeySequence(Qt.Key_Greater | Qt.AltModifier)),
+ ('<alt+less>',
+ keyutils.KeySequence(Qt.Key_Less | Qt.AltModifier)),
+
+ ('<alt+<>', keyutils.KeyParseError),
+ ('<alt+>>', keyutils.KeyParseError),
+ ('<blub>', keyutils.KeyParseError),
+ ('<>', keyutils.KeyParseError),
+ ('\U00010000', keyutils.KeyParseError),
+ ])
+ def test_parse(self, keystr, expected):
+ if expected is keyutils.KeyParseError:
+ with pytest.raises(keyutils.KeyParseError):
+ keyutils.KeySequence.parse(keystr)
+ else:
+ assert keyutils.KeySequence.parse(keystr) == expected
+
+ @hypothesis.given(strategies.text())
+ def test_parse_hypothesis(self, keystr):
+ try:
+ seq = keyutils.KeySequence.parse(keystr)
+ except keyutils.KeyParseError:
+ pass
+ else:
+ str(seq)
+
+
+def test_key_info_from_event():
+ ev = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.ShiftModifier, 'A')
+ info = keyutils.KeyInfo.from_event(ev)
+ assert info.key == Qt.Key_A
+ assert info.modifiers == Qt.ShiftModifier
+
+
+def test_key_info_to_event():
+ info = keyutils.KeyInfo(Qt.Key_A, Qt.ShiftModifier)
+ ev = info.to_event()
+ assert ev.key() == Qt.Key_A
+ assert ev.modifiers() == Qt.ShiftModifier
+ assert ev.text() == 'A'
+
+
+def test_key_info_to_int():
+ info = keyutils.KeyInfo(Qt.Key_A, Qt.ShiftModifier)
+ assert info.to_int() == Qt.Key_A | Qt.ShiftModifier
+
+
+@pytest.mark.parametrize('key, printable', [
+ (Qt.Key_Control, False),
+ (Qt.Key_Escape, False),
+ (Qt.Key_Tab, False),
+ (Qt.Key_Backtab, False),
+ (Qt.Key_Backspace, False),
+ (Qt.Key_Return, False),
+ (Qt.Key_Enter, False),
+ (Qt.Key_Space, False),
+ (0x0, False), # Used by Qt for unknown keys
+
+ (Qt.Key_ydiaeresis, True),
+ (Qt.Key_X, True),
+])
+def test_is_printable(key, printable):
+ assert keyutils._is_printable(key) == printable
+ assert keyutils.is_special(key, Qt.NoModifier) != printable
+
+
+@pytest.mark.parametrize('key, modifiers, special', [
+ (Qt.Key_Escape, Qt.NoModifier, True),
+ (Qt.Key_Escape, Qt.ShiftModifier, True),
+ (Qt.Key_Escape, Qt.ControlModifier, True),
+ (Qt.Key_X, Qt.ControlModifier, True),
+ (Qt.Key_X, Qt.NoModifier, False),
+])
+def test_is_special(key, modifiers, special):
+ assert keyutils.is_special(key, modifiers) == special
+
+
+@pytest.mark.parametrize('key, ismodifier', [
+ (Qt.Key_Control, True),
+ (Qt.Key_X, False),
+ (Qt.Key_Super_L, False), # Modifier but not in _MODIFIER_MAP
+])
+def test_is_modifier_key(key, ismodifier):
+ assert keyutils.is_modifier_key(key) == ismodifier
+
+
+@pytest.mark.parametrize('func', [
+ keyutils._assert_plain_key,
+ keyutils._assert_plain_modifier,
+ keyutils._is_printable,
+ keyutils.is_modifier_key,
+ keyutils._key_to_string,
+ keyutils._modifiers_to_string,
+])
+def test_non_plain(func):
+ with pytest.raises(AssertionError):
+ func(Qt.Key_X | Qt.ControlModifier)
diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py
index 221b675be..25b3fc776 100644
--- a/tests/unit/keyinput/test_modeman.py
+++ b/tests/unit/keyinput/test_modeman.py
@@ -34,7 +34,7 @@ class FakeKeyparser(QObject):
super().__init__()
self.passthrough = False
- def handle(self, evt):
+ def handle(self, evt, *, dry_run=False):
return False
@@ -44,15 +44,14 @@ def modeman(mode_manager):
return mode_manager
-@pytest.mark.parametrize('key, modifiers, text, filtered', [
- (Qt.Key_A, Qt.NoModifier, 'a', True),
- (Qt.Key_Up, Qt.NoModifier, '', False),
+@pytest.mark.parametrize('key, modifiers, filtered', [
+ (Qt.Key_A, Qt.NoModifier, True),
+ (Qt.Key_Up, Qt.NoModifier, False),
# https://github.com/qutebrowser/qutebrowser/issues/1207
- (Qt.Key_A, Qt.ShiftModifier, 'A', True),
- (Qt.Key_A, Qt.ShiftModifier | Qt.ControlModifier, 'x', False),
+ (Qt.Key_A, Qt.ShiftModifier, True),
+ (Qt.Key_A, Qt.ShiftModifier | Qt.ControlModifier, False),
])
-def test_non_alphanumeric(key, modifiers, text, filtered,
- fake_keyevent_factory, modeman):
+def test_non_alphanumeric(key, modifiers, filtered, fake_keyevent, modeman):
"""Make sure non-alphanumeric keys are passed through correctly."""
- evt = fake_keyevent_factory(key=key, modifiers=modifiers, text=text)
- assert modeman.eventFilter(evt) == filtered
+ evt = fake_keyevent(key=key, modifiers=modifiers)
+ assert modeman.handle_event(evt) == filtered
diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py
index ade8c15cc..cd1f110bc 100644
--- a/tests/unit/keyinput/test_modeparsers.py
+++ b/tests/unit/keyinput/test_modeparsers.py
@@ -22,20 +22,15 @@
from unittest import mock
from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QKeySequence
import pytest
-from qutebrowser.keyinput import modeparsers
+from qutebrowser.keyinput import modeparsers, keyutils
class TestsNormalKeyParser:
- """Tests for NormalKeyParser.
-
- Attributes:
- kp: The NormalKeyParser to be tested.
- """
-
@pytest.fixture(autouse=True)
def patch_stuff(self, monkeypatch, stubs, keyinput_bindings):
"""Set up mocks and read the test config."""
@@ -49,36 +44,55 @@ class TestsNormalKeyParser:
kp.execute = mock.Mock()
return kp
- def test_keychain(self, keyparser, fake_keyevent_factory):
+ def test_keychain(self, keyparser, fake_keyevent):
"""Test valid keychain."""
# Press 'x' which is ignored because of no match
- keyparser.handle(fake_keyevent_factory(Qt.Key_X, text='x'))
+ keyparser.handle(fake_keyevent(Qt.Key_X))
# Then start the real chain
- keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b'))
- keyparser.handle(fake_keyevent_factory(Qt.Key_A, text='a'))
- keyparser.execute.assert_called_with(
- 'message-info ba', keyparser.Type.chain, None)
- assert keyparser._keystring == ''
+ keyparser.handle(fake_keyevent(Qt.Key_B))
+ keyparser.handle(fake_keyevent(Qt.Key_A))
+ keyparser.execute.assert_called_with('message-info ba', None)
+ assert not keyparser._sequence
def test_partial_keychain_timeout(self, keyparser, config_stub,
- fake_keyevent_factory):
+ fake_keyevent):
"""Test partial keychain timeout."""
config_stub.val.input.partial_timeout = 100
timer = keyparser._partial_timer
assert not timer.isActive()
# Press 'b' for a partial match.
# Then we check if the timer has been set up correctly
- keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b'))
+ keyparser.handle(fake_keyevent(Qt.Key_B))
assert timer.isSingleShot()
assert timer.interval() == 100
assert timer.isActive()
assert not keyparser.execute.called
- assert keyparser._keystring == 'b'
+ assert keyparser._sequence == keyutils.KeySequence.parse('b')
# Now simulate a timeout and check the keystring has been cleared.
keystring_updated_mock = mock.Mock()
keyparser.keystring_updated.connect(keystring_updated_mock)
timer.timeout.emit()
assert not keyparser.execute.called
- assert keyparser._keystring == ''
+ assert not keyparser._sequence
keystring_updated_mock.assert_called_once_with('')
+
+
+class TestHintKeyParser:
+
+ @pytest.fixture
+ def keyparser(self, config_stub, key_config_stub):
+ kp = modeparsers.HintKeyParser(0)
+ kp.execute = mock.Mock()
+ kp.keystring_updated.disconnect() # Don't try to update HintManager
+ return kp
+
+ def test_simple_hint_match(self, keyparser, fake_keyevent):
+ keyparser.update_bindings(['aa', 'as'])
+
+ match = keyparser.handle(fake_keyevent(Qt.Key_A))
+ assert match == QKeySequence.PartialMatch
+ match = keyparser.handle(fake_keyevent(Qt.Key_S))
+ assert match == QKeySequence.ExactMatch
+
+ keyparser.execute.assert_called_with('follow-hint -s as', None)
diff --git a/tests/unit/mainwindow/statusbar/test_backforward.py b/tests/unit/mainwindow/statusbar/test_backforward.py
index 6e594c0d2..11e3da616 100644
--- a/tests/unit/mainwindow/statusbar/test_backforward.py
+++ b/tests/unit/mainwindow/statusbar/test_backforward.py
@@ -43,8 +43,8 @@ def test_backforward_widget(backforward_widget, tabbed_browser_stubs,
"""Ensure the Backforward widget shows the correct text."""
tab = fake_web_tab(can_go_back=can_go_back, can_go_forward=can_go_forward)
tabbed_browser = tabbed_browser_stubs[0]
- tabbed_browser.current_index = 1
- tabbed_browser.tabs = [tab]
+ tabbed_browser.widget.current_index = 1
+ tabbed_browser.widget.tabs = [tab]
backforward_widget.enabled = True
backforward_widget.on_tab_cur_url_changed(tabbed_browser)
assert backforward_widget.text() == expected_text
@@ -59,7 +59,7 @@ def test_backforward_widget(backforward_widget, tabbed_browser_stubs,
# Check that the widget gets reset if empty.
if can_go_back and can_go_forward:
tab = fake_web_tab(can_go_back=False, can_go_forward=False)
- tabbed_browser.tabs = [tab]
+ tabbed_browser.widget.tabs = [tab]
backforward_widget.enabled = True
backforward_widget.on_tab_cur_url_changed(tabbed_browser)
assert backforward_widget.text() == ''
@@ -70,15 +70,15 @@ def test_none_tab(backforward_widget, tabbed_browser_stubs, fake_web_tab):
"""Make sure nothing crashes when passing None as tab."""
tab = fake_web_tab(can_go_back=True, can_go_forward=True)
tabbed_browser = tabbed_browser_stubs[0]
- tabbed_browser.current_index = 1
- tabbed_browser.tabs = [tab]
+ tabbed_browser.widget.current_index = 1
+ tabbed_browser.widget.tabs = [tab]
backforward_widget.enabled = True
backforward_widget.on_tab_cur_url_changed(tabbed_browser)
assert backforward_widget.text() == '[<>]'
assert backforward_widget.isVisible()
- tabbed_browser.current_index = -1
+ tabbed_browser.widget.current_index = -1
backforward_widget.on_tab_cur_url_changed(tabbed_browser)
assert backforward_widget.text() == ''
diff --git a/tests/unit/mainwindow/test_prompt.py b/tests/unit/mainwindow/test_prompt.py
index eb0ba9e69..7c8d2b0ad 100644
--- a/tests/unit/mainwindow/test_prompt.py
+++ b/tests/unit/mainwindow/test_prompt.py
@@ -56,32 +56,43 @@ class TestFileCompletion:
def test_simple_completion(self, tmpdir, get_prompt, steps, where,
subfolder):
"""Simply trying to tab through items."""
+ testdir = tmpdir / 'test'
for directory in 'abc':
- (tmpdir / directory).ensure(dir=True)
+ (testdir / directory).ensure(dir=True)
- prompt = get_prompt(str(tmpdir) + os.sep)
+ prompt = get_prompt(str(testdir) + os.sep)
for _ in range(steps):
prompt.item_focus(where)
- assert prompt._lineedit.text() == str(tmpdir / subfolder)
+ assert prompt._lineedit.text() == str(testdir / subfolder)
def test_backspacing_path(self, qtbot, tmpdir, get_prompt):
"""When we start deleting a path we want to see the subdir."""
+ testdir = tmpdir / 'test'
+
for directory in ['bar', 'foo']:
- (tmpdir / directory).ensure(dir=True)
+ (testdir / directory).ensure(dir=True)
- prompt = get_prompt(str(tmpdir / 'foo') + os.sep)
+ prompt = get_prompt(str(testdir / '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)
+ # foo should get completed from f
+ prompt.item_focus('next')
+ assert prompt._lineedit.text() == str(testdir / 'foo')
+
+ # Deleting /[foo]
+ 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')
+ assert prompt._lineedit.text() == str(testdir / 'bar')
@pytest.mark.linux
def test_root_path(self, get_prompt):
diff --git a/tests/unit/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py
index 7ad22fcc3..36e6a0c48 100644
--- a/tests/unit/mainwindow/test_tabwidget.py
+++ b/tests/unit/mainwindow/test_tabwidget.py
@@ -71,7 +71,7 @@ class TestTabWidget:
with qtbot.waitExposed(widget):
widget.show()
- benchmark(widget._update_tab_titles)
+ benchmark(widget.update_tab_titles)
@pytest.mark.parametrize("num_tabs", [4, 10])
def test_add_remove_tab_benchmark(self, benchmark, browser,
@@ -79,7 +79,7 @@ class TestTabWidget:
"""Benchmark for addTab and removeTab."""
def _run_bench():
for i in range(num_tabs):
- browser.addTab(fake_web_tab(), 'foobar' + str(i))
+ browser.widget.addTab(fake_web_tab(), 'foobar' + str(i))
with qtbot.waitExposed(browser):
browser.show()
diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py
index 8cf778d89..94021484a 100644
--- a/tests/unit/misc/test_editor.py
+++ b/tests/unit/misc/test_editor.py
@@ -157,6 +157,45 @@ class TestFileHandling:
with pytest.raises(ValueError):
editor.edit("")
+ def test_backup(self, qtbot, message_mock):
+ editor = editormod.ExternalEditor(watch=True)
+ editor.edit('foo')
+ with qtbot.wait_signal(editor.file_updated):
+ _update_file(editor._filename, 'bar')
+
+ editor.backup()
+
+ msg = message_mock.getmsg(usertypes.MessageLevel.info)
+ prefix = 'Editor backup at '
+ assert msg.text.startswith(prefix)
+ fname = msg.text[len(prefix):]
+
+ with qtbot.wait_signal(editor.editing_finished):
+ editor._proc.finished.emit(0, QProcess.NormalExit)
+
+ with open(fname, 'r', encoding='utf-8') as f:
+ assert f.read() == 'bar'
+
+ def test_backup_no_content(self, qtbot, message_mock):
+ editor = editormod.ExternalEditor(watch=True)
+ editor.edit('foo')
+ editor.backup()
+ # content has not changed, so no backup should be created
+ assert not message_mock.messages
+
+ def test_backup_error(self, qtbot, message_mock, mocker, caplog):
+ editor = editormod.ExternalEditor(watch=True)
+ editor.edit('foo')
+ with qtbot.wait_signal(editor.file_updated):
+ _update_file(editor._filename, 'bar')
+
+ mocker.patch('tempfile.NamedTemporaryFile', side_effect=OSError)
+ with caplog.at_level(logging.ERROR):
+ editor.backup()
+
+ msg = message_mock.getmsg(usertypes.MessageLevel.error)
+ assert msg.text.startswith('Failed to create editor backup:')
+
@pytest.mark.parametrize('initial_text, edited_text', [
('', 'Hello'),
diff --git a/tests/unit/misc/test_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py
index ccf3b5593..d6b099418 100644
--- a/tests/unit/misc/test_miscwidgets.py
+++ b/tests/unit/misc/test_miscwidgets.py
@@ -106,7 +106,7 @@ class TestFullscreenNotification:
@pytest.mark.parametrize('bindings, text', [
({'<escape>': 'fullscreen --leave'},
- "Press Escape to exit fullscreen."),
+ "Press <Escape> to exit fullscreen."),
({'<escape>': 'fullscreen'}, "Page is now fullscreen."),
({'a': 'fullscreen --leave'}, "Press a to exit fullscreen."),
({}, "Page is now fullscreen."),
diff --git a/tests/unit/misc/test_pastebin.py b/tests/unit/misc/test_pastebin.py
index b352f52c8..cbd6f4c3e 100644
--- a/tests/unit/misc/test_pastebin.py
+++ b/tests/unit/misc/test_pastebin.py
@@ -18,36 +18,14 @@
# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
import pytest
-from PyQt5.QtCore import pyqtSignal, QUrl, QObject
+from PyQt5.QtCore import QUrl
from qutebrowser.misc import httpclient, pastebin
-class HTTPPostStub(QObject):
-
- """A stub class for HTTPClient.
-
- Attributes:
- url: the last url send by post()
- data: the last data send by post()
- """
-
- success = pyqtSignal(str)
- error = pyqtSignal(str)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.url = None
- self.data = None
-
- def post(self, url, data=None):
- self.url = url
- self.data = data
-
-
@pytest.fixture
-def pbclient():
- http_stub = HTTPPostStub()
+def pbclient(stubs):
+ http_stub = stubs.HTTPPostStub()
client = pastebin.PastebinClient(http_stub)
return client
@@ -101,6 +79,20 @@ def test_paste_without_parent(data, pbclient):
assert http_stub.url == QUrl('https://crashes.qutebrowser.org/api/create')
+def test_paste_private(pbclient):
+ data = {
+ "name": "the name",
+ "title": "the title",
+ "text": "some Text",
+ "apikey": "ihatespam",
+ "private": "1",
+ }
+ http_stub = pbclient._client
+ pbclient.paste(data["name"], data["title"], data["text"], private=True)
+ assert pbclient._client.data == data
+ assert http_stub.url == QUrl('https://crashes.qutebrowser.org/api/create')
+
+
@pytest.mark.parametrize('http', [
"http://paste.the-compiler.org/view/ges83nt3",
"http://paste.the-compiler.org/view/3gjnwg4"
diff --git a/tests/unit/misc/test_utilcmds.py b/tests/unit/misc/test_utilcmds.py
index c63c7bf86..cfa115412 100644
--- a/tests/unit/misc/test_utilcmds.py
+++ b/tests/unit/misc/test_utilcmds.py
@@ -153,6 +153,6 @@ def tabbed_browser(stubs, win_registry):
objreg.delete('tabbed-browser', scope='window', window=0)
-def test_version(tabbed_browser):
+def test_version(tabbed_browser, qapp):
utilcmds.version(win_id=0)
assert tabbed_browser.opened_url == QUrl('qute://version')
diff --git a/tests/unit/utils/test_jinja.py b/tests/unit/utils/test_jinja.py
index f47155f91..b1a50772e 100644
--- a/tests/unit/utils/test_jinja.py
+++ b/tests/unit/utils/test_jinja.py
@@ -23,6 +23,7 @@ import os
import os.path
import logging
+import jinja2.exceptions
import pytest
from PyQt5.QtCore import QUrl
@@ -32,7 +33,6 @@ from qutebrowser.utils import utils, jinja
@pytest.fixture(autouse=True)
def patch_read_file(monkeypatch):
"""pytest fixture to patch utils.read_file."""
- real_read_file = utils.read_file
real_resource_filename = utils.resource_filename
def _read_file(path, binary=False):
@@ -52,9 +52,6 @@ def patch_read_file(monkeypatch):
elif path == os.path.join('html', 'undef.html'):
assert not binary
return """{{ does_not_exist() }}"""
- elif path == os.path.join('html', 'undef_error.html'):
- assert not binary
- return real_read_file(path)
elif path == os.path.join('html', 'attributeerror.html'):
assert not binary
return """{{ obj.foobar }}"""
@@ -129,15 +126,9 @@ def test_utf8():
def test_undefined_function(caplog):
- """Make sure we don't crash if an undefined function is called."""
- with caplog.at_level(logging.ERROR):
- data = jinja.render('undef.html')
- assert 'There was an error while rendering undef.html' in data
- assert "'does_not_exist' is undefined" in data
- assert data.startswith('<!DOCTYPE html>')
-
- assert len(caplog.records) == 1
- assert caplog.records[0].msg == "UndefinedError while rendering undef.html"
+ """Make sure undefined attributes crash since we preload resources.."""
+ with pytest.raises(jinja2.exceptions.UndefinedError):
+ jinja.render('undef.html')
def test_attribute_error():
diff --git a/tests/unit/utils/test_urlmatch.py b/tests/unit/utils/test_urlmatch.py
new file mode 100644
index 000000000..dcd703790
--- /dev/null
+++ b/tests/unit/utils/test_urlmatch.py
@@ -0,0 +1,571 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>.
+
+"""Tests for qutebrowser.utils.urlmatch.
+
+The tests are mostly inspired by Chromium's:
+https://cs.chromium.org/chromium/src/extensions/common/url_pattern_unittest.cc
+
+Currently not tested:
+- The match_effective_tld attribute as it doesn't exist yet.
+- Nested filesystem:// URLs as we don't have those.
+- Unicode matching because QUrl doesn't like those URLs.
+- Any other features we don't need, such as .GetAsString() or set operations.
+"""
+
+import re
+import sys
+import string
+
+import pytest
+import hypothesis
+import hypothesis.strategies as hst
+from PyQt5.QtCore import QUrl
+
+from qutebrowser.utils import urlmatch
+
+
+@pytest.mark.parametrize('pattern, error', [
+ # Chromium: PARSE_ERROR_MISSING_SCHEME_SEPARATOR
+ # ("http", "No scheme given"),
+ ("http:", "Invalid port: Port is empty"),
+ ("http:/", "Invalid port: Port is empty"),
+ ("about://", "Pattern without path"),
+ ("http:/bar", "Invalid port: Port is empty"),
+
+ # Chromium: PARSE_ERROR_EMPTY_HOST
+ ("http://", "Pattern without host"),
+ ("http:///", "Pattern without host"),
+ ("http:// /", "Pattern without host"),
+ ("http://:1234/", "Pattern without host"),
+
+ # Chromium: PARSE_ERROR_EMPTY_PATH
+ # We deviate from Chromium and allow this for ease of use
+ # ("http://bar", "..."),
+
+ # Chromium: PARSE_ERROR_INVALID_HOST
+ ("http://\0www/", "May not contain NUL byte"),
+
+ # Chromium: PARSE_ERROR_INVALID_HOST_WILDCARD
+ ("http://*foo/bar", "Invalid host wildcard"),
+ ("http://foo.*.bar/baz", "Invalid host wildcard"),
+ ("http://fo.*.ba:123/baz", "Invalid host wildcard"),
+ ("http://foo.*/bar", "TLD wildcards are not implemented yet"),
+
+ # Chromium: PARSE_ERROR_INVALID_PORT
+ ("http://foo:/", "Invalid port: Port is empty"),
+ ("http://*.foo:/", "Invalid port: Port is empty"),
+ ("http://foo:com/",
+ "Invalid port: invalid literal for int() with base 10: 'com'"),
+ pytest.param("http://foo:123456/",
+ "Invalid port: Port out of range 0-65535",
+ marks=pytest.mark.skipif(
+ sys.hexversion < 0x03060000,
+ reason="Doesn't show an error on Python 3.5")),
+ ("http://foo:80:80/monkey",
+ "Invalid port: invalid literal for int() with base 10: '80:80'"),
+ ("chrome://foo:1234/bar", "Ports are unsupported with chrome scheme"),
+
+ # Additional tests
+ ("http://[", "Invalid IPv6 URL"),
+])
+def test_invalid_patterns(pattern, error):
+ with pytest.raises(urlmatch.ParseError, match=re.escape(error)):
+ urlmatch.UrlPattern(pattern)
+
+
+@pytest.mark.parametrize('pattern, port', [
+ ("http://foo:1234/", 1234),
+ ("http://foo:1234/bar", 1234),
+ ("http://*.foo:1234/", 1234),
+ ("http://*.foo:1234/bar", 1234),
+ ("http://*:1234/", 1234),
+ ("http://*:*/", None),
+ ("http://foo:*/", None),
+ ("file://foo:1234/bar", None),
+
+ # Port-like strings in the path should not trigger a warning.
+ ("http://*/:1234", None),
+ ("http://*.foo/bar:1234", None),
+ ("http://foo/bar:1234/path", None),
+ # We don't implement ALLOW_WILDCARD_FOR_EFFECTIVE_TLD yet.
+ # ("http://*.foo.*/:1234", None),
+])
+def test_port(pattern, port):
+ up = urlmatch.UrlPattern(pattern)
+ assert up._port == port
+
+
+@pytest.mark.parametrize('pattern, path', [
+ ("http://foo/", '/'),
+ ("http://foo/*", None),
+])
+def test_parse_path(pattern, path):
+ up = urlmatch.UrlPattern(pattern)
+ assert up._path == path
+
+
+@pytest.mark.parametrize('pattern, scheme, host, path', [
+ ("http://example.com", 'http', 'example.com', None), # no path
+ ("example.com/path", None, 'example.com', '/path'), # no scheme
+ ("example.com", None, 'example.com', None), # no scheme and no path
+ ("example.com:1234", None, 'example.com', None), # no scheme/path but port
+ ("data:monkey", 'data', None, 'monkey'), # existing scheme
+])
+def test_lightweight_patterns(pattern, scheme, host, path):
+ """Make sure we can leave off parts of an URL.
+
+ This is a deviation from Chromium to make patterns more user-friendly.
+ """
+ up = urlmatch.UrlPattern(pattern)
+ assert up._scheme == scheme
+ assert up._host == host
+ assert up._path == path
+
+
+class TestMatchAllPagesForGivenScheme:
+
+ @pytest.fixture
+ def up(self):
+ return urlmatch.UrlPattern("http://*/*")
+
+ def test_attrs(self, up):
+ assert up._scheme == 'http'
+ assert up._host is None
+ assert up._match_subdomains
+ assert not up._match_all
+ assert up._path is None
+
+ @pytest.mark.parametrize('url, expected', [
+ ("http://google.com", True),
+ ("http://yahoo.com", True),
+ ("http://google.com/foo", True),
+ ("https://google.com", False),
+ ("http://74.125.127.100/search", True),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+class TestMatchAllDomains:
+
+ @pytest.fixture
+ def up(self):
+ return urlmatch.UrlPattern("https://*/foo*")
+
+ def test_attrs(self, up):
+ assert up._scheme == 'https'
+ assert up._host is None
+ assert up._match_subdomains
+ assert not up._match_all
+ assert up._path == '/foo*'
+
+ @pytest.mark.parametrize('url, expected', [
+ ("https://google.com/foo", True),
+ ("https://google.com/foobar", True),
+ ("http://google.com/foo", False),
+ ("https://google.com/", False),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+class TestMatchSubdomains:
+
+ @pytest.fixture
+ def up(self):
+ return urlmatch.UrlPattern("http://*.google.com/foo*bar")
+
+ def test_attrs(self, up):
+ assert up._scheme == 'http'
+ assert up._host == 'google.com'
+ assert up._match_subdomains
+ assert not up._match_all
+ assert up._path == '/foo*bar'
+
+ @pytest.mark.parametrize('url, expected', [
+ ("http://google.com/foobar", True),
+ # FIXME The ?bar seems to be treated as path by GURL but as query by
+ # QUrl.
+ # ("http://www.google.com/foo?bar", True),
+ ("http://monkey.images.google.com/foooobar", True),
+ ("http://yahoo.com/foobar", False),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+class TestMatchGlobEscaping:
+
+ @pytest.fixture
+ def up(self):
+ return urlmatch.UrlPattern(r"file:///foo-bar\*baz")
+
+ def test_attrs(self, up):
+ assert up._scheme == 'file'
+ assert up._host is None
+ assert not up._match_subdomains
+ assert not up._match_all
+ assert up._path == r'/foo-bar\*baz'
+
+ @pytest.mark.parametrize('url, expected', [
+ # We use - instead of ? so it doesn't get treated as query
+ (r"file:///foo-bar\hellobaz", True),
+ (r"file:///fooXbar\hellobaz", False),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+class TestMatchIpAddresses:
+
+ @pytest.mark.parametrize('pattern, host, match_subdomains', [
+ ("http://127.0.0.1/*", "127.0.0.1", False),
+ ("http://*.0.0.1/*", "0.0.1", True),
+ ])
+ def test_attrs(self, pattern, host, match_subdomains):
+ up = urlmatch.UrlPattern(pattern)
+ assert up._scheme == 'http'
+ assert up._host == host
+ assert up._match_subdomains == match_subdomains
+ assert not up._match_all
+ assert up._path is None
+
+ @pytest.mark.parametrize('pattern, expected', [
+ ("http://127.0.0.1/*", True),
+ # No subdomain matching is done with IPs
+ ("http://*.0.0.1/*", False),
+ ])
+ def test_urls(self, pattern, expected):
+ up = urlmatch.UrlPattern(pattern)
+ assert up.matches(QUrl("http://127.0.0.1")) == expected
+
+
+class TestMatchChromeUrls:
+
+ @pytest.fixture
+ def up(self):
+ return urlmatch.UrlPattern("chrome://favicon/*")
+
+ def test_attrs(self, up):
+ assert up._scheme == 'chrome'
+ assert up._host == 'favicon'
+ assert not up._match_subdomains
+ assert not up._match_all
+ assert up._path is None
+
+ @pytest.mark.parametrize('url, expected', [
+ ("chrome://favicon/http://google.com", True),
+ ("chrome://favicon/https://google.com", True),
+ ("chrome://history", False),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+class TestMatchAnything:
+
+ @pytest.fixture(params=['*://*/*', '*://*:*/*', '<all_urls>', '*://*'])
+ def up(self, request):
+ return urlmatch.UrlPattern(request.param)
+
+ def test_attrs_common(self, up):
+ assert up._scheme is None
+ assert up._host is None
+ assert up._path is None
+
+ def test_attrs_wildcard(self):
+ up = urlmatch.UrlPattern('*://*/*')
+ assert up._match_subdomains
+ assert not up._match_all
+
+ def test_attrs_all(self):
+ up = urlmatch.UrlPattern('<all_urls>')
+ assert not up._match_subdomains
+ assert up._match_all
+
+ @pytest.mark.parametrize('url', [
+ "http://127.0.0.1",
+ # We deviate from Chromium as we allow other schemes as well
+ "chrome://favicon/http://google.com",
+ "file:///foo/bar",
+ "file://localhost/foo/bar",
+ "qute://version",
+ "about:blank",
+ "data:text/html;charset=utf-8,<html>asdf</html>",
+ ])
+ def test_urls(self, up, url):
+ assert up.matches(QUrl(url))
+
+
+@pytest.mark.parametrize('pattern, url, expected', [
+ ("about:*", "about:blank", True),
+ ("about:blank", "about:blank", True),
+ ("about:*", "about:version", True),
+ ("data:*", "data:monkey", True),
+ ("javascript:*", "javascript:atemyhomework", True),
+ ("data:*", "about:blank", False),
+])
+def test_special_schemes(pattern, url, expected):
+ assert urlmatch.UrlPattern(pattern).matches(QUrl(url)) == expected
+
+
+class TestFileScheme:
+
+ @pytest.fixture(params=[
+ 'file:///foo*',
+ 'file://foo*',
+ # FIXME This doesn't pass all tests
+ pytest.param('file://localhost/foo*', marks=pytest.mark.skip(
+ reason="We're not handling this correctly in all cases"))
+ ])
+ def up(self, request):
+ return urlmatch.UrlPattern(request.param)
+
+ def test_attrs(self, up):
+ assert up._scheme == 'file'
+ assert up._host is None
+ assert not up._match_subdomains
+ assert not up._match_all
+ assert up._path == '/foo*'
+
+ @pytest.mark.parametrize('url, expected', [
+ ("file://foo", False),
+ ("file://foobar", False),
+ ("file:///foo", True),
+ ("file:///foobar", True),
+ ("file://localhost/foo", True),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+class TestMatchSpecificPort:
+
+ @pytest.fixture
+ def up(self):
+ return urlmatch.UrlPattern("http://www.example.com:80/foo")
+
+ def test_attrs(self, up):
+ assert up._scheme == 'http'
+ assert up._host == 'www.example.com'
+ assert not up._match_subdomains
+ assert not up._match_all
+ assert up._path == '/foo'
+ assert up._port == 80
+
+ @pytest.mark.parametrize('url, expected', [
+ ("http://www.example.com:80/foo", True),
+ ("http://www.example.com/foo", True),
+ ("http://www.example.com:8080/foo", False),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+class TestExplicitPortWildcard:
+
+ @pytest.fixture
+ def up(self):
+ return urlmatch.UrlPattern("http://www.example.com:*/foo")
+
+ def test_attrs(self, up):
+ assert up._scheme == 'http'
+ assert up._host == 'www.example.com'
+ assert not up._match_subdomains
+ assert not up._match_all
+ assert up._path == '/foo'
+ assert up._port is None
+
+ @pytest.mark.parametrize('url, expected', [
+ ("http://www.example.com:80/foo", True),
+ ("http://www.example.com/foo", True),
+ ("http://www.example.com:8080/foo", True),
+ ])
+ def test_urls(self, up, url, expected):
+ assert up.matches(QUrl(url)) == expected
+
+
+def test_ignore_missing_slashes():
+ pattern1 = urlmatch.UrlPattern("http://www.example.com/example")
+ pattern2 = urlmatch.UrlPattern("http://www.example.com/example/*")
+ url1 = QUrl('http://www.example.com/example')
+ url2 = QUrl('http://www.example.com/example/')
+
+ # Same patterns should match same URLs.
+ assert pattern1.matches(url1)
+ assert pattern2.matches(url1)
+ # The not terminated path should match the terminated pattern.
+ assert pattern2.matches(url1)
+ # The terminated path however should not match the unterminated pattern.
+ assert not pattern1.matches(url2)
+
+
+def test_trailing_slash():
+ """Contrary to Chromium, we allow to leave off a trailing slash."""
+ url = QUrl('http://www.example.com/')
+ pattern = urlmatch.UrlPattern('http://www.example.com')
+ assert pattern.matches(url)
+
+
+@pytest.mark.parametrize('pattern', ['*://example.com/*',
+ '*://example.com./*'])
+@pytest.mark.parametrize('url', ['http://example.com/',
+ 'http://example.com./'])
+def test_trailing_dot_domain(pattern, url):
+ """Both patterns should match trailing dot and non trailing dot domains.
+
+ More information about this not obvious behavior can be found in [1].
+
+ RFC 1738 [2] specifies clearly that the <host> part of a URL is supposed to
+ contain a fully qualified domain name:
+
+ 3.1. Common Internet Scheme Syntax
+ //<user>:<password>@<host>:<port>/<url-path>
+
+ host
+ The fully qualified domain name of a network host
+
+ [1] http://www.dns-sd.org./TrailingDotsInDomainNames.html
+ [2] http://www.ietf.org/rfc/rfc1738.txt
+ """
+ assert urlmatch.UrlPattern(pattern).matches(QUrl(url))
+
+
+def test_urlpattern_benchmark(benchmark):
+ url = QUrl('https://www.example.com/barfoobar')
+
+ def run():
+ up = urlmatch.UrlPattern('https://*.example.com/*foo*')
+ up.matches(url)
+
+ benchmark(run)
+
+
+URL_TEXT = hst.text(alphabet=string.ascii_letters)
+
+
+@hypothesis.given(pattern=hst.builds(
+ lambda *a: ''.join(a),
+ # Scheme
+ hst.one_of(hst.just('*'), hst.just('http'), hst.just('file')),
+ # Separator
+ hst.one_of(hst.just(':'), hst.just('://')),
+ # Host
+ hst.one_of(hst.just('*'),
+ hst.builds(lambda *a: ''.join(a), hst.just('*.'), URL_TEXT),
+ URL_TEXT),
+ # Port
+ hst.one_of(hst.just(''),
+ hst.builds(lambda *a: ''.join(a), hst.just(':'),
+ hst.integers(min_value=0,
+ max_value=65535).map(str))),
+ # Path
+ hst.one_of(hst.just(''),
+ hst.builds(lambda *a: ''.join(a), hst.just('/'), URL_TEXT))
+))
+def test_urlpattern_hypothesis(pattern):
+ try:
+ up = urlmatch.UrlPattern(pattern)
+ except urlmatch.ParseError:
+ return
+ up.matches(QUrl('https://www.example.com/'))
+
+
+@pytest.mark.parametrize('text1, text2, equal', [
+ # schemes
+ ("http://en.google.com/blah/*/foo",
+ "https://en.google.com/blah/*/foo",
+ False),
+ ("https://en.google.com/blah/*/foo",
+ "https://en.google.com/blah/*/foo",
+ True),
+ ("https://en.google.com/blah/*/foo",
+ "ftp://en.google.com/blah/*/foo",
+ False),
+
+ # subdomains
+ ("https://en.google.com/blah/*/foo",
+ "https://fr.google.com/blah/*/foo",
+ False),
+ ("https://www.google.com/blah/*/foo",
+ "https://*.google.com/blah/*/foo",
+ False),
+ ("https://*.google.com/blah/*/foo",
+ "https://*.google.com/blah/*/foo",
+ True),
+
+ # domains
+ ("http://en.example.com/blah/*/foo",
+ "http://en.google.com/blah/*/foo",
+ False),
+
+ # ports
+ ("http://en.google.com:8000/blah/*/foo",
+ "http://en.google.com/blah/*/foo",
+ False),
+ ("http://fr.google.com:8000/blah/*/foo",
+ "http://fr.google.com:8000/blah/*/foo",
+ True),
+ ("http://en.google.com:8000/blah/*/foo",
+ "http://en.google.com:8080/blah/*/foo",
+ False),
+
+ # paths
+ ("http://en.google.com/blah/*/foo",
+ "http://en.google.com/blah/*",
+ False),
+ ("http://en.google.com/*",
+ "http://en.google.com/",
+ False),
+ ("http://en.google.com/*",
+ "http://en.google.com/*",
+ True),
+
+ # all_urls
+ ("<all_urls>",
+ "<all_urls>",
+ True),
+ ("<all_urls>",
+ "http://*/*",
+ False)
+])
+def test_equal(text1, text2, equal):
+ pat1 = urlmatch.UrlPattern(text1)
+ pat2 = urlmatch.UrlPattern(text2)
+
+ assert (pat1 == pat2) == equal
+ assert (hash(pat1) == hash(pat2)) == equal
+
+
+def test_equal_string():
+ assert urlmatch.UrlPattern("<all_urls>") != '<all_urls>'
+
+
+def test_repr():
+ pat = urlmatch.UrlPattern('https://www.example.com/')
+ expected = ("qutebrowser.utils.urlmatch.UrlPattern("
+ "pattern='https://www.example.com/')")
+ assert repr(pat) == expected
+
+
+def test_str():
+ text = 'https://www.example.com/'
+ pat = urlmatch.UrlPattern(text)
+ assert str(pat) == text
diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py
index 4b8c50813..df0eb9ecb 100644
--- a/tests/unit/utils/test_utils.py
+++ b/tests/unit/utils/test_utils.py
@@ -30,7 +30,7 @@ import re
import shlex
import attr
-from PyQt5.QtCore import Qt, QUrl
+from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QColor, QClipboard
import pytest
@@ -133,6 +133,14 @@ class TestReadFile:
content = utils.read_file(os.path.join('utils', 'testfile'))
assert content.splitlines()[0] == "Hello World!"
+ @pytest.mark.parametrize('filename', ['javascript/scroll.js',
+ 'html/error.html'])
+ def test_read_cached_file(self, mocker, filename):
+ utils.preload_resources()
+ m = mocker.patch('pkg_resources.resource_string')
+ utils.read_file(filename)
+ m.assert_not_called()
+
def test_readfile_binary(self):
"""Read a test file in binary mode."""
content = utils.read_file(os.path.join('utils', 'testfile'),
@@ -297,134 +305,6 @@ class TestFormatSize:
assert utils.format_size(size, base=1000) == out
-class TestKeyToString:
-
- """Test key_to_string."""
-
- @pytest.mark.parametrize('key, expected', [
- (Qt.Key_Blue, 'Blue'),
- (Qt.Key_Backtab, 'Tab'),
- (Qt.Key_Escape, 'Escape'),
- (Qt.Key_A, 'A'),
- (Qt.Key_degree, '°'),
- (Qt.Key_Meta, 'Meta'),
- ])
- def test_normal(self, key, expected):
- """Test a special key where QKeyEvent::toString works incorrectly."""
- assert utils.key_to_string(key) == expected
-
- def test_missing(self, monkeypatch):
- """Test with a missing key."""
- monkeypatch.delattr(utils.Qt, 'Key_Blue')
- # We don't want to test the key which is actually missing - we only
- # want to know if the mapping still behaves properly.
- assert utils.key_to_string(Qt.Key_A) == 'A'
-
- def test_all(self):
- """Make sure there's some sensible output for all keys."""
- for name, value in sorted(vars(Qt).items()):
- if not isinstance(value, Qt.Key):
- continue
- print(name)
- string = utils.key_to_string(value)
- assert string
- string.encode('utf-8') # make sure it's encodable
-
-
-class TestKeyEventToString:
-
- """Test keyevent_to_string."""
-
- def test_only_control(self, fake_keyevent_factory):
- """Test keyeevent when only control is pressed."""
- evt = fake_keyevent_factory(key=Qt.Key_Control,
- modifiers=Qt.ControlModifier)
- assert utils.keyevent_to_string(evt) is None
-
- def test_only_hyper_l(self, fake_keyevent_factory):
- """Test keyeevent when only Hyper_L is pressed."""
- evt = fake_keyevent_factory(key=Qt.Key_Hyper_L,
- modifiers=Qt.MetaModifier)
- assert utils.keyevent_to_string(evt) is None
-
- def test_only_key(self, fake_keyevent_factory):
- """Test with a simple key pressed."""
- evt = fake_keyevent_factory(key=Qt.Key_A)
- assert utils.keyevent_to_string(evt) == 'a'
-
- def test_key_and_modifier(self, fake_keyevent_factory):
- """Test with key and modifier pressed."""
- evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier)
- expected = 'meta+a' if utils.is_mac else 'ctrl+a'
- assert utils.keyevent_to_string(evt) == expected
-
- def test_key_and_modifiers(self, fake_keyevent_factory):
- """Test with key and multiple modifiers pressed."""
- evt = fake_keyevent_factory(
- key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier |
- Qt.MetaModifier | Qt.ShiftModifier))
- assert utils.keyevent_to_string(evt) == 'ctrl+alt+meta+shift+a'
-
- @pytest.mark.fake_os('mac')
- def test_mac(self, fake_keyevent_factory):
- """Test with a simulated mac."""
- evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier)
- assert utils.keyevent_to_string(evt) == 'meta+a'
-
-
-@pytest.mark.parametrize('keystr, expected', [
- ('<Control-x>', utils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')),
- ('<Meta-x>', utils.KeyInfo(Qt.Key_X, Qt.MetaModifier, '')),
- ('<Ctrl-Alt-y>',
- utils.KeyInfo(Qt.Key_Y, Qt.ControlModifier | Qt.AltModifier, '')),
- ('x', utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')),
- ('X', utils.KeyInfo(Qt.Key_X, Qt.ShiftModifier, 'X')),
- ('<Escape>', utils.KeyInfo(Qt.Key_Escape, Qt.NoModifier, '')),
-
- ('foobar', utils.KeyParseError),
- ('x, y', utils.KeyParseError),
- ('xyz', utils.KeyParseError),
- ('Escape', utils.KeyParseError),
- ('<Ctrl-x>, <Ctrl-y>', utils.KeyParseError),
-])
-def test_parse_single_key(keystr, expected):
- if expected is utils.KeyParseError:
- with pytest.raises(utils.KeyParseError):
- utils._parse_single_key(keystr)
- else:
- assert utils._parse_single_key(keystr) == expected
-
-
-@pytest.mark.parametrize('keystr, expected', [
- ('<Control-x>', [utils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')]),
- ('x', [utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')]),
- ('xy', [utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x'),
- utils.KeyInfo(Qt.Key_Y, Qt.NoModifier, 'y')]),
-
- ('<Control-x><Meta-x>', utils.KeyParseError),
-])
-def test_parse_keystring(keystr, expected):
- if expected is utils.KeyParseError:
- with pytest.raises(utils.KeyParseError):
- utils.parse_keystring(keystr)
- else:
- assert utils.parse_keystring(keystr) == expected
-
-
-@pytest.mark.parametrize('orig, repl', [
- ('Control+x', 'ctrl+x'),
- ('Windows+x', 'meta+x'),
- ('Mod1+x', 'alt+x'),
- ('Mod4+x', 'meta+x'),
- ('Control--', 'ctrl+-'),
- ('Windows++', 'meta++'),
- ('ctrl-x', 'ctrl+x'),
- ('control+x', 'ctrl+x')
-])
-def test_normalize_keystr(orig, repl):
- assert utils.normalize_keystr(orig) == repl
-
-
class TestFakeIOStream:
"""Test FakeIOStream."""
@@ -832,22 +712,6 @@ class TestGetSetClipboard:
utils.get_clipboard(fallback=True)
-@pytest.mark.parametrize('keystr, expected', [
- ('<Control-x>', True),
- ('<Meta-x>', True),
- ('<Ctrl-Alt-y>', True),
- ('x', False),
- ('X', False),
- ('<Escape>', True),
- ('foobar', False),
- ('foo>', False),
- ('<foo', False),
- ('<<', False),
-])
-def test_is_special_key(keystr, expected):
- assert utils.is_special_key(keystr) == expected
-
-
def test_random_port():
port = utils.random_port()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -931,3 +795,19 @@ class TestYaml:
with tmpfile.open('w', encoding='utf-8') as f:
utils.yaml_dump([1, 2], f)
assert tmpfile.read() == '- 1\n- 2\n'
+
+
+@pytest.mark.parametrize('elems, n, expected', [
+ ([], 1, []),
+ ([1], 1, [[1]]),
+ ([1, 2], 2, [[1, 2]]),
+ ([1, 2, 3, 4], 2, [[1, 2], [3, 4]]),
+])
+def test_chunk(elems, n, expected):
+ assert list(utils.chunk(elems, n)) == expected
+
+
+@pytest.mark.parametrize('n', [-1, 0])
+def test_chunk_invalid(n):
+ with pytest.raises(ValueError):
+ list(utils.chunk([], n))
diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py
index da65422a7..fe45fec97 100644
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -38,6 +38,7 @@ import pytest
import qutebrowser
from qutebrowser.utils import version, usertypes, utils
+from qutebrowser.misc import pastebin
from qutebrowser.browser import pdfjs
@@ -647,6 +648,8 @@ class TestModuleVersions:
name: The name of the module to check.
has_version: Whether a __version__ attribute is expected.
"""
+ if name == 'cssutils':
+ pytest.importorskip(name)
module = importlib.import_module(name)
assert hasattr(module, '__version__') == has_version
@@ -704,6 +707,15 @@ class TestOsInfo:
expected = ['OS Version: {}'.format(mac_ver_str)]
assert ret == expected
+ @pytest.mark.fake_os('posix')
+ def test_posix_fake(self, monkeypatch):
+ """Test with a fake posix platform."""
+ uname_tuple = ('PosixOS', 'localhost', '1.0', '1.0', 'i386', 'i386')
+ monkeypatch.setattr(version.platform, 'uname', lambda: uname_tuple)
+ ret = version._os_info()
+ expected = ['OS Version: PosixOS localhost 1.0 1.0 i386 i386']
+ assert ret == expected
+
@pytest.mark.fake_os('unknown')
def test_unknown_fake(self):
"""Test with a fake unknown platform."""
@@ -726,6 +738,11 @@ class TestOsInfo:
"""Make sure there are no exceptions with a real macOS."""
version._os_info()
+ @pytest.mark.posix
+ def test_posix_real(self):
+ """Make sure there are no exceptions with a real posix."""
+ version._os_info()
+
class TestPDFJSVersion:
@@ -946,7 +963,76 @@ def test_version_output(params, stubs, monkeypatch):
assert version.version() == expected
-def test_opengl_vendor():
+def test_opengl_vendor(qapp):
"""Simply call version.opengl_vendor() and see if it doesn't crash."""
pytest.importorskip("PyQt5.QtOpenGL")
return version.opengl_vendor()
+
+
+@pytest.fixture
+def pbclient(stubs):
+ http_stub = stubs.HTTPPostStub()
+ client = pastebin.PastebinClient(http_stub)
+ yield client
+ version.pastebin_url = None
+
+
+def test_pastebin_version(pbclient, message_mock, monkeypatch, qtbot):
+ """Test version.pastebin_version() sets the url."""
+ monkeypatch.setattr('qutebrowser.utils.version.version',
+ lambda: "dummy")
+ monkeypatch.setattr('qutebrowser.utils.utils.log_clipboard', True)
+
+ version.pastebin_version(pbclient)
+ pbclient.success.emit("test")
+
+ msg = message_mock.getmsg(usertypes.MessageLevel.info)
+ assert msg.text == "Version url test yanked to clipboard."
+ assert version.pastebin_url == "test"
+
+
+def test_pastebin_version_twice(pbclient, monkeypatch):
+ """Test whether calling pastebin_version twice sends no data."""
+ monkeypatch.setattr('qutebrowser.utils.version.version',
+ lambda: "dummy")
+
+ version.pastebin_version(pbclient)
+ pbclient.success.emit("test")
+
+ pbclient.url = None
+ pbclient.data = None
+ version.pastebin_url = "test2"
+
+ version.pastebin_version(pbclient)
+ assert pbclient.url is None
+ assert pbclient.data is None
+ assert version.pastebin_url == "test2"
+
+
+def test_pastebin_version_error(pbclient, caplog, message_mock, monkeypatch):
+ """Test version.pastebin_version() with errors."""
+ monkeypatch.setattr('qutebrowser.utils.version.version',
+ lambda: "dummy")
+
+ version.pastebin_url = None
+ with caplog.at_level(logging.ERROR):
+ version.pastebin_version(pbclient)
+ pbclient._client.error.emit("test")
+
+ assert version.pastebin_url is None
+
+ msg = message_mock.getmsg(usertypes.MessageLevel.error)
+ assert msg.text == "Failed to pastebin version info: test"
+
+
+def test_uptime(monkeypatch, qapp):
+ """Test _uptime runs and check if microseconds are dropped."""
+ launch_time = datetime.datetime(1, 1, 1, 1, 1, 1, 1)
+ monkeypatch.setattr(qapp, "launch_time", launch_time, raising=False)
+
+ class FakeDateTime(datetime.datetime):
+ now = lambda x=datetime.datetime(1, 1, 1, 1, 1, 1, 2): x
+ monkeypatch.setattr('datetime.datetime', FakeDateTime)
+
+ uptime_delta = version._uptime()
+ assert uptime_delta == datetime.timedelta(0)
diff --git a/tox.ini b/tox.ini
index f09f92165..4e155df57 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,8 +13,8 @@ skipsdist = true
setenv =
QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms
PYTEST_QT_API=pyqt5
- pyqt{,56,571,58,59,510}: LINK_PYQT_SKIP=true
- pyqt{,56,571,58,59,510}: QUTE_BDD_WEBENGINE=true
+ pyqt{,56,571,59,510}: LINK_PYQT_SKIP=true
+ pyqt{,56,571,59,510}: QUTE_BDD_WEBENGINE=true
cov: PYTEST_ADDOPTS=--cov --cov-report xml --cov-report=html --cov-report=
passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI TRAVIS XDG_* QUTE_* DOCKER
basepython =
@@ -26,9 +26,8 @@ deps =
-r{toxinidir}/misc/requirements/requirements-tests.txt
pyqt: -r{toxinidir}/misc/requirements/requirements-pyqt.txt
pyqt571: PyQt5==5.7.1
- pyqt58: PyQt5==5.8.2
pyqt59: PyQt5==5.9.2
- pyqt510: PyQt5==5.10
+ pyqt510: PyQt5==5.10.1
commands =
{envpython} scripts/link_pyqt.py --tox {envdir}
{envpython} -bb -m pytest {posargs:tests}
@@ -60,7 +59,7 @@ commands = {envpython} -c ""
usedevelop = true
deps =
-r{toxinidir}/requirements.txt
- -r{toxinidir}/misc/requirements/requirements-pyqt.txt
+ -r{toxinidir}/misc/requirements/requirements-pyqt-old.txt
[testenv:misc]
ignore_errors = true