summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.bumpversion.cfg2
-rw-r--r--.coveragerc3
-rw-r--r--.github/workflows/ci.yml4
-rw-r--r--.pylintrc3
-rw-r--r--doc/changelog.asciidoc50
-rw-r--r--doc/help/commands.asciidoc20
-rw-r--r--doc/help/settings.asciidoc14
-rw-r--r--doc/install.asciidoc8
-rwxr-xr-xmisc/nsis/install.nsh12
-rwxr-xr-xmisc/nsis/qutebrowser.nsi3
-rw-r--r--misc/org.qutebrowser.qutebrowser.appdata.xml1
-rw-r--r--misc/qutebrowser.spec6
-rw-r--r--misc/requirements/requirements-check-manifest.txt4
-rw-r--r--misc/requirements/requirements-dev.txt20
-rw-r--r--misc/requirements/requirements-flake8.txt7
-rw-r--r--misc/requirements/requirements-mypy.txt8
-rw-r--r--misc/requirements/requirements-pyinstaller.txt4
-rw-r--r--misc/requirements/requirements-pylint.txt17
-rw-r--r--misc/requirements/requirements-pyroma.txt8
-rw-r--r--misc/requirements/requirements-sphinx.txt12
-rw-r--r--misc/requirements/requirements-tests-bleeding.txt2
-rw-r--r--misc/requirements/requirements-tests.txt17
-rw-r--r--misc/requirements/requirements-tox.txt8
-rw-r--r--misc/requirements/requirements-vulture.txt2
-rwxr-xr-xmisc/userscripts/qute-keepassxc94
-rwxr-xr-xmisc/userscripts/qutedmenu6
-rwxr-xr-xmisc/userscripts/readability-js3
-rw-r--r--qutebrowser/__init__.py2
-rw-r--r--qutebrowser/browser/browsertab.py81
-rw-r--r--qutebrowser/browser/commands.py112
-rw-r--r--qutebrowser/browser/inspector.py5
-rw-r--r--qutebrowser/browser/shared.py4
-rw-r--r--qutebrowser/browser/webengine/notification.py9
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py239
-rw-r--r--qutebrowser/browser/webkit/http.py7
-rw-r--r--qutebrowser/browser/webkit/webkittab.py46
-rw-r--r--qutebrowser/completion/completer.py4
-rw-r--r--qutebrowser/completion/models/configmodel.py18
-rw-r--r--qutebrowser/components/misccommands.py88
-rw-r--r--qutebrowser/config/configdata.yml16
-rw-r--r--qutebrowser/config/configtypes.py10
-rw-r--r--qutebrowser/html/settings.html5
-rw-r--r--qutebrowser/mainwindow/mainwindow.py3
-rw-r--r--qutebrowser/mainwindow/prompt.py26
-rw-r--r--qutebrowser/mainwindow/statusbar/bar.py88
-rw-r--r--qutebrowser/mainwindow/statusbar/clock.py54
-rw-r--r--qutebrowser/mainwindow/statusbar/searchmatch.py48
-rw-r--r--qutebrowser/mainwindow/tabbedbrowser.py5
-rw-r--r--qutebrowser/misc/elf.py2
-rw-r--r--qutebrowser/utils/utils.py15
-rw-r--r--requirements.txt2
-rwxr-xr-xscripts/asciidoc2html.py5
-rw-r--r--scripts/dev/changelog_urls.json7
-rw-r--r--scripts/dev/check_coverage.py2
-rw-r--r--scripts/dev/run_pylint_on_tests.py2
-rwxr-xr-xscripts/dev/src2asciidoc.py16
-rw-r--r--tests/end2end/data/click_element.html6
-rw-r--r--tests/end2end/features/conftest.py32
-rw-r--r--tests/end2end/features/downloads.feature29
-rw-r--r--tests/end2end/features/misc.feature45
-rw-r--r--tests/end2end/features/search.feature83
-rw-r--r--tests/end2end/features/test_editor_bdd.py30
-rw-r--r--tests/unit/browser/test_caret.py4
-rw-r--r--tests/unit/browser/webengine/test_webenginetab.py43
-rw-r--r--tests/unit/browser/webkit/http/test_http.py15
-rw-r--r--tests/unit/browser/webkit/test_webkitelem.py1
-rw-r--r--tests/unit/misc/test_elf.py17
-rw-r--r--tests/unit/utils/test_log.py1
-rw-r--r--tests/unit/utils/test_utils.py60
-rw-r--r--tests/unit/utils/test_version.py6
70 files changed, 1188 insertions, 443 deletions
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 83a5b985a..5ae20070b 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 2.5.0
+current_version = 2.5.1
commit = True
message = Release v{new_version}
tag = True
diff --git a/.coveragerc b/.coveragerc
index 7c4f7b218..cb0619b80 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -23,6 +23,3 @@ exclude_lines =
[xml]
output=coverage.xml
-
-[html]
-show_contexts = True
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 28f1d0971..288efbc02 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -198,12 +198,12 @@ jobs:
with:
persist-credentials: false
- name: Initialize CodeQL
- uses: github/codeql-action/init@v1
+ uses: github/codeql-action/init@v2
with:
languages: javascript, python
queries: +security-extended
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
+ uses: github/codeql-action/analyze@v2
irc:
timeout-minutes: 2
diff --git a/.pylintrc b/.pylintrc
index c5a1289fb..47d3a163d 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -23,10 +23,8 @@ py-version=3.7
[MESSAGES CONTROL]
enable=all
disable=locally-disabled,
- locally-enabled,
suppressed-message,
fixme,
- no-self-use,
cyclic-import,
blacklisted-name,
logging-format-interpolation,
@@ -51,7 +49,6 @@ disable=locally-disabled,
too-many-statements,
too-few-public-methods,
import-outside-toplevel,
- bad-continuation, # This lint disagrees with Black
consider-using-f-string,
logging-fstring-interpolation,
raise-missing-from,
diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc
index 233a86865..32940da44 100644
--- a/doc/changelog.asciidoc
+++ b/doc/changelog.asciidoc
@@ -24,6 +24,14 @@ Added
- On invalid commands/settings with a similarly spelled match, qutebrowser now
suggests the correct name in its error messages.
+- New `:prompt-fileselect-external` command which can be used to spawn an
+ external file selector (`fileselect.folder.command`) from download filename
+ prompts (bound to `<Alt+e>` by default).
+- New `clock` value for `statusbar.widgets`, displaying the current time.
+- New features in userscripts:
+ * `qutedmenu` gained new `window` and `private` options.
+ * `qute-keepassxc` now supports unlock-on-demand, multiple account
+ selection via rofi, and inserting TOTP-codes (experimental).
Removed
~~~~~~~
@@ -54,9 +62,42 @@ Changed
rather than being a boolean: `none` (formerly `false`), `access` (formerly
`true`) and `access-paste` (additionally allows pasting content, needed for
websites like Photopea or GitHub Codespaces).
+- The default `hints.selectors` now also match the `treeitem` ARIA roles.
+- The `:click-element` command now can also click elements based on its ID
+ (`id`), a CSS selector (`css`), a position (`position`), or click the
+ currently focused element (`focused`).
+- The `:click-element` command now can select the first found element via
+ `--select-first`.
+- New `search.wrap_messages` setting, making it possible to disable search
+ wrapping messages.
+- New widgets for `statusbar.widgets`:
+ * `clock`, showing the current time
+ * `search_match`, showing the current match and total count when finding text
+ on a page
+
+Fixed
+~~~~~
+
+- When the devtools are clicked but `input.insert_mode.auto_enter` is set to
+ `false`, insert mode now isn't entered anymore.
+- The search wrapping messages are now correctly displayed in (hopefully) all
+ cases with QtWebEngine.
+
+[[v2.5.2]]
+v2.5.2 (unreleased)
+-------------------
+
+Fixed
+~~~~~
+
+- The `install` and `stacktrace` help pages are now included in the docs
+ shipped with qutebrowser when using the recommended packaging workflow.
+- The Windows installer now more consistently uses the configured Windows colors
+- The Windows installer now bases the desktop/start menu icon choices on the
+ existing install, if upgrading.
[[v2.5.1]]
-v2.5.1 (unreleased)
+v2.5.1 (2022-05-26)
-------------------
Fixed
@@ -66,11 +107,18 @@ Fixed
- PDF.js now works properly again with the macOS and Windows releases.
- The MathML workaround for darkmode (e.g. black on black Wikipedia formula)
now also works for display (rather than inline) math.
+- The `content.proxy` setting can now correctly be set to arbitrary values via
+ the `qute://settings` page again.
+- Fixed issues with Chromium version detection on Archlinux with
+ qt5-webengine 5.15.9-3.
+- Fixed a rare possible crash with invalid `Content-Disposition` headers.
- Fixes for various notification-related crashes:
* With the `tiramisu` notification server (due to invalid behavior of the server, now a non-fatal error)
* With the `budgie` notification server when closing a notification (due to invalid behavior of the server, now worked around)
* When a server exits with an unsuccessful exit status (now a non-fatal error)
* When a server couldn't be started successfully (now a non-fatal error)
+ * With the `herbe` notification presenter, when the website tries to close
+ the notification after the user accepting (right-clicking) it.
- Fixes in userscripts:
* The `qute-bitwarden` userscript now correctly searches for entries for
sites on a subdomain of an unrecognized TLD. subdomain names. Previously
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index 6003c0f1f..d38bb5a87 100644
--- a/doc/help/commands.asciidoc
+++ b/doc/help/commands.asciidoc
@@ -254,20 +254,27 @@ Clear all message notifications.
[[click-element]]
=== click-element
-Syntax: +:click-element [*--target* 'target'] [*--force-event*] 'filter' 'value'+
+Syntax: +:click-element [*--target* 'target'] [*--force-event*] [*--select-first*] 'filter' ['value']+
Click the element matching the given filter.
The given filter needs to result in exactly one element, otherwise, an error is shown.
==== positional arguments
-* +'filter'+: How to filter the elements. id: Get an element based on its ID.
+* +'filter'+: How to filter the elements.
-* +'value'+: The value to filter for.
+ - id: Get an element based on its ID.
+ - css: Filter by a CSS selector.
+ - position: Click the element at specified position.
+ Specify `value` as 'x,y'.
+ - focused: Click the currently focused element.
+
+* +'value'+: The value to filter for. Optional for 'focused' filter.
==== optional arguments
* +*-t*+, +*--target*+: How to open the clicked element (normal/tab/tab-bg/window).
* +*-f*+, +*--force-event*+: Force generating a fake click event.
+* +*-s*+, +*--select-first*+: Select first matching element if there are multiple.
[[close]]
=== close
@@ -1671,6 +1678,7 @@ How many steps to zoom out.
|<<move-to-start-of-next-block,move-to-start-of-next-block>>|Move the cursor or selection to the start of next block.
|<<move-to-start-of-prev-block,move-to-start-of-prev-block>>|Move the cursor or selection to the start of previous block.
|<<prompt-accept,prompt-accept>>|Accept the current prompt.
+|<<prompt-fileselect-external,prompt-fileselect-external>>|Choose a location using a configured external picker.
|<<prompt-item-focus,prompt-item-focus>>|Shift the focus of the prompt file completion menu to another item.
|<<prompt-open-download,prompt-open-download>>|Immediately open a download.
|<<prompt-yank,prompt-yank>>|Yank URL to clipboard or primary selection.
@@ -1859,6 +1867,12 @@ Accept the current prompt.
==== optional arguments
* +*-s*+, +*--save*+: Save the value to the config.
+[[prompt-fileselect-external]]
+=== prompt-fileselect-external
+Choose a location using a configured external picker.
+
+This spawns the external fileselector configured via `fileselect.folder.command`.
+
[[prompt-item-focus]]
=== prompt-item-focus
Syntax: +:prompt-item-focus 'which'+
diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc
index f43548629..b16fe2a06 100644
--- a/doc/help/settings.asciidoc
+++ b/doc/help/settings.asciidoc
@@ -303,6 +303,7 @@
|<<search.ignore_case,search.ignore_case>>|When to find text on a page case-insensitively.
|<<search.incremental,search.incremental>>|Find text on a page incrementally, renewing the search for each typed character.
|<<search.wrap,search.wrap>>|Wrap around at the top and bottom of the page when advancing through text matches using `:search-next` and `:search-prev`.
+|<<search.wrap_messages,search.wrap_messages>>|Display messages when advancing through text matches at the top and bottom of the page, e.g. `Search hit TOP`.
|<<session.default_name,session.default_name>>|Name of the session to save by default.
|<<session.lazy_restore,session.lazy_restore>>|Load a restored tab as soon as it takes focus.
|<<spellcheck.languages,spellcheck.languages>>|Languages to use for spell checking.
@@ -750,6 +751,7 @@ Default:
* +pass:[&lt;Alt-B&gt;]+: +pass:[rl-backward-word]+
* +pass:[&lt;Alt-Backspace&gt;]+: +pass:[rl-backward-kill-word]+
* +pass:[&lt;Alt-D&gt;]+: +pass:[rl-kill-word]+
+* +pass:[&lt;Alt-E&gt;]+: +pass:[prompt-fileselect-external]+
* +pass:[&lt;Alt-F&gt;]+: +pass:[rl-forward-word]+
* +pass:[&lt;Alt-Shift-Y&gt;]+: +pass:[prompt-yank --sel]+
* +pass:[&lt;Alt-Y&gt;]+: +pass:[prompt-yank]+
@@ -3447,6 +3449,7 @@ Default:
* +pass:[[role=&quot;menuitem&quot;\]]+
* +pass:[[role=&quot;menuitemcheckbox&quot;\]]+
* +pass:[[role=&quot;menuitemradio&quot;\]]+
+* +pass:[[role=&quot;treeitem&quot;\]]+
* +pass:[[ng-click\]]+
* +pass:[[ngClick\]]+
* +pass:[[data-ng-click\]]+
@@ -4000,6 +4003,14 @@ Type: <<types,Bool>>
Default: +pass:[true]+
+[[search.wrap_messages]]
+=== search.wrap_messages
+Display messages when advancing through text matches at the top and bottom of the page, e.g. `Search hit TOP`.
+
+Type: <<types,Bool>>
+
+Default: +pass:[true]+
+
[[session.default_name]]
=== session.default_name
Name of the session to save by default.
@@ -4126,14 +4137,17 @@ Valid values:
* +scroll+: Percentage of the current page position like `10%`.
* +scroll_raw+: Raw percentage of the current page position like `10`.
* +history+: Display an arrow when possible to go back/forward in history.
+ * +search_match+: A match count when searching, e.g. `Match [2/10]`.
* +tabs+: Current active tab, e.g. `2`.
* +keypress+: Display pressed keys when composing a vi command.
* +progress+: Progress bar for the current page loading.
* +text:foo+: Display the static text after the colon, `foo` in the example.
+ * +clock+: Display current time. The format can be changed by adding a format string via `clock:...`. For supported format strings, see https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes[the Python datetime documentation].
Default:
- +pass:[keypress]+
+- +pass:[search_match]+
- +pass:[url]+
- +pass:[scroll]+
- +pass:[history]+
diff --git a/doc/install.asciidoc b/doc/install.asciidoc
index bb4e08f5a..0f44e5a91 100644
--- a/doc/install.asciidoc
+++ b/doc/install.asciidoc
@@ -48,7 +48,7 @@ instructions!
Note you'll need some basic libraries to use the virtualenv-installed PyQt:
----
-# apt install --no-install-recommends git ca-certificates python3 python3-venv asciidoc libglib2.0-0 libgl1 libfontconfig1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0 libdbus-1-3 libyaml-dev gcc python3-dev libnss3
+# apt install --no-install-recommends git ca-certificates python3 python3-venv asciidoc libglib2.0-0 libgl1 libfontconfig1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0 libdbus-1-3 libyaml-dev gcc python3-dev libnss3 libasound2
----
// FIXME not needed anymore?
@@ -69,6 +69,12 @@ from January 2018 is packaged, with QtWebEngine 5.9 based on a Chromium from Jan
2017. It's recommended to either upgrade to Ubuntu 20.04 LTS or <<tox,install
qutebrowser in a virtualenv>> with a newer PyQt/Qt binary instead.
+Note you'll need some basic libraries to use the virtualenv-installed PyQt:
+
+----
+# apt install --no-install-recommends git ca-certificates python3 python3-venv asciidoc libglib2.0-0 libgl1 libfontconfig1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxkbcommon-x11-0 libdbus-1-3 libyaml-dev gcc python3-dev libnss3 libasound2
+----
+
Ubuntu 20.04 LTS / Linux Mint 20 (or newer)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/misc/nsis/install.nsh b/misc/nsis/install.nsh
index 9f0cdf446..8233ab5f0 100755
--- a/misc/nsis/install.nsh
+++ b/misc/nsis/install.nsh
@@ -542,8 +542,16 @@ Function PageInstallModeChangeMode
FunctionEnd
Function PageComponentsPre
- GetDlgItem $0 $HWNDPARENT 1
- SendMessage $0 ${BCM_SETSHIELD} 0 0 ; hide SHIELD (Windows Vista and above)
+ SendMessage $mui.Button.Next ${BCM_SETSHIELD} 0 0
+ StrCmpS $HasCurrentModeInstallation 0 +9
+ IfFileExists "$DESKTOP\${PRODUCT_NAME}.lnk" +4
+ SectionGetFlags ${SectionDesktopIcon} $1
+ IntOp $1 $1 & 0xFFFFFFFE
+ SectionSetFlags ${SectionDesktopIcon} $1
+ IfFileExists "$STARTMENU\${PRODUCT_NAME}.lnk" +4
+ SectionGetFlags ${SectionStartMenuIcon} $1
+ IntOp $1 $1 & 0xFFFFFFFE
+ SectionSetFlags ${SectionStartMenuIcon} $1
FunctionEnd
Function PageDirectoryPre
diff --git a/misc/nsis/qutebrowser.nsi b/misc/nsis/qutebrowser.nsi
index 43214d9c8..7623d8cb2 100755
--- a/misc/nsis/qutebrowser.nsi
+++ b/misc/nsis/qutebrowser.nsi
@@ -43,6 +43,9 @@ ShowUninstDetails hide
!addplugindir /x86-unicode ".\plugins\x86-unicode"
!addincludedir ".\include"
+!define MUI_BGCOLOR "SYSCLR:Window"
+!define MUI_TEXTCOLOR "SYSCLR:WindowText"
+
!include MUI2.nsh
!include NsisMultiUser.nsh
!include StdUtils.nsh
diff --git a/misc/org.qutebrowser.qutebrowser.appdata.xml b/misc/org.qutebrowser.qutebrowser.appdata.xml
index a18511b73..f506bd5a8 100644
--- a/misc/org.qutebrowser.qutebrowser.appdata.xml
+++ b/misc/org.qutebrowser.qutebrowser.appdata.xml
@@ -44,6 +44,7 @@
</content_rating>
<releases>
<!-- Add new releases here -->
+<release version="2.5.1" date="2022-05-26"/>
<release version="2.5.0" date="2022-04-01"/>
<release version="2.4.0" date="2021-10-21"/>
<release version="2.3.1" date="2021-07-28"/>
diff --git a/misc/qutebrowser.spec b/misc/qutebrowser.spec
index 3c75d1b90..60729266e 100644
--- a/misc/qutebrowser.spec
+++ b/misc/qutebrowser.spec
@@ -2,7 +2,6 @@
import sys
import os
-import pathlib
sys.path.insert(0, os.getcwd())
from scripts import setupcommon
@@ -42,10 +41,7 @@ setupcommon.write_git_file()
if os.name == 'nt':
- # WORKAROUND for PyInstaller 5.0 bug:
- # https://github.com/pyinstaller/pyinstaller/issues/6759
- icons_path = pathlib.Path.cwd() / 'qutebrowser' / 'icons'
- icon = str(icons_path / 'qutebrowser.ico')
+ icon = '../qutebrowser/icons/qutebrowser.ico'
elif sys.platform == 'darwin':
icon = '../qutebrowser/icons/qutebrowser.icns'
else:
diff --git a/misc/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt
index 012a3dc05..c51ef3d0e 100644
--- a/misc/requirements/requirements-check-manifest.txt
+++ b/misc/requirements/requirements-check-manifest.txt
@@ -1,8 +1,8 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-build==0.7.0
+build==0.8.0
check-manifest==0.48
packaging==21.3
pep517==0.12.0
-pyparsing==3.0.8
+pyparsing==3.0.9
tomli==2.0.1
diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt
index 9a4bb1a39..e4e768353 100644
--- a/misc/requirements/requirements-dev.txt
+++ b/misc/requirements/requirements-dev.txt
@@ -1,9 +1,9 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
bleach==5.0.0
-build==0.7.0
+build==0.8.0
bump2version==1.0.1
-certifi==2021.10.8
+certifi==2022.5.18.1
cffi==1.15.0
charset-normalizer==2.0.12
commonmark==0.9.1
@@ -12,32 +12,32 @@ docutils==0.18.1
github3.py==3.2.0
hunter==3.4.3
idna==3.3
-importlib-metadata==4.11.3
+importlib-metadata==4.11.4
jeepney==0.8.0
-keyring==23.5.0
+keyring==23.6.0
manhole==1.8.0
packaging==21.3
pep517==0.12.0
-pkginfo==1.8.2
+pkginfo==1.8.3
ply==3.11
pycparser==2.21
Pygments==2.12.0
-PyJWT==2.3.0
+PyJWT==2.4.0
Pympler==1.0.1
-pyparsing==3.0.8
+pyparsing==3.0.9
PyQt-builder==1.12.2
python-dateutil==2.8.2
readme-renderer==35.0
-requests==2.27.1
+requests==2.28.0
requests-toolbelt==0.9.1
rfc3986==2.0.0
-rich==12.4.1
+rich==12.4.4
SecretStorage==3.3.2
sip==6.6.1
six==1.16.0
toml==0.10.2
tomli==2.0.1
-twine==4.0.0
+twine==4.0.1
typing_extensions==4.2.0
uritemplate==4.1.1
# urllib3==1.26.9
diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt
index 0efee2d73..217089191 100644
--- a/misc/requirements/requirements-flake8.txt
+++ b/misc/requirements/requirements-flake8.txt
@@ -4,7 +4,7 @@ attrs==21.4.0
flake8==4.0.1
flake8-bugbear==22.4.25
flake8-builtins==1.5.3
-flake8-comprehensions==3.8.0
+flake8-comprehensions==3.10.0
flake8-copyright==0.2.2
flake8-debugger==4.1.2
flake8-deprecated==1.3
@@ -12,13 +12,12 @@ flake8-docstrings==1.6.0
flake8-future-import==0.4.6
flake8-mock==0.3
flake8-plugin-utils==1.3.2
-flake8-polyfill==1.0.2
flake8-pytest-style==1.6.0
flake8-string-format==0.3.0
-flake8-tidy-imports==4.7.0
+flake8-tidy-imports==4.8.0
flake8-tuple==0.4.1
mccabe==0.6.1
-pep8-naming==0.12.1
+pep8-naming==0.13.0
pycodestyle==2.8.0
pydocstyle==6.1.1
pyflakes==2.4.0
diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt
index 2f62bf818..a4b555cf3 100644
--- a/misc/requirements/requirements-mypy.txt
+++ b/misc/requirements/requirements-mypy.txt
@@ -2,17 +2,17 @@
chardet==4.0.0
diff-cover==6.5.0
-importlib-metadata==4.11.3
+importlib-metadata==4.11.4
importlib-resources==5.7.1
Jinja2==3.1.2
-lxml==4.8.0
+lxml==4.9.0
MarkupSafe==2.1.1
-mypy==0.950
+mypy==0.961
mypy-extensions==0.4.3
pluggy==1.0.0
Pygments==2.12.0
PyQt5-stubs==5.15.6.0
tomli==2.0.1
-types-PyYAML==6.0.7
+types-PyYAML==6.0.8
typing_extensions==4.2.0
zipp==3.8.0
diff --git a/misc/requirements/requirements-pyinstaller.txt b/misc/requirements/requirements-pyinstaller.txt
index 8700478e5..35e65b6da 100644
--- a/misc/requirements/requirements-pyinstaller.txt
+++ b/misc/requirements/requirements-pyinstaller.txt
@@ -1,5 +1,5 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
altgraph==0.17.2
-pyinstaller==5.0.1
-pyinstaller-hooks-contrib==2022.4
+pyinstaller==5.1
+pyinstaller-hooks-contrib==2022.7
diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt
index 4bcc7ca53..38231fa12 100644
--- a/misc/requirements/requirements-pylint.txt
+++ b/misc/requirements/requirements-pylint.txt
@@ -1,28 +1,29 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-astroid==2.11.4
-certifi==2021.10.8
+astroid==2.11.5
+certifi==2022.5.18.1
cffi==1.15.0
charset-normalizer==2.0.12
cryptography==37.0.2
-dill==0.3.4
+dill==0.3.5.1
future==0.18.2
github3.py==3.2.0
idna==3.3
isort==5.10.1
lazy-object-proxy==1.7.1
mccabe==0.7.0
-pefile==2021.9.3
+pefile==2022.5.30
platformdirs==2.5.2
pycparser==2.21
-PyJWT==2.3.0
-pylint==2.13.8
+PyJWT==2.4.0
+pylint==2.14.1
python-dateutil==2.8.2
./scripts/dev/pylint_checkers
-requests==2.27.1
+requests==2.28.0
six==1.16.0
tomli==2.0.1
-typed-ast==1.5.3 ; python_version<"3.8"
+tomlkit==0.11.0
+typed-ast==1.5.4 ; python_version<"3.8"
typing_extensions==4.2.0
uritemplate==4.1.1
# urllib3==1.26.9
diff --git a/misc/requirements/requirements-pyroma.txt b/misc/requirements/requirements-pyroma.txt
index d17b46593..382418dd9 100644
--- a/misc/requirements/requirements-pyroma.txt
+++ b/misc/requirements/requirements-pyroma.txt
@@ -1,15 +1,15 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
-build==0.7.0
-certifi==2021.10.8
+build==0.8.0
+certifi==2022.5.18.1
charset-normalizer==2.0.12
docutils==0.18.1
idna==3.3
packaging==21.3
pep517==0.12.0
Pygments==2.12.0
-pyparsing==3.0.8
+pyparsing==3.0.9
pyroma==4.0
-requests==2.27.1
+requests==2.28.0
tomli==2.0.1
urllib3==1.26.9
diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt
index abf2a2720..f100b6dc0 100644
--- a/misc/requirements/requirements-sphinx.txt
+++ b/misc/requirements/requirements-sphinx.txt
@@ -2,21 +2,21 @@
alabaster==0.7.12
Babel==2.10.1
-certifi==2021.10.8
+certifi==2022.5.18.1
charset-normalizer==2.0.12
-docutils==0.17.1
+docutils==0.18.1
idna==3.3
imagesize==1.3.0
-importlib-metadata==4.11.3
+importlib-metadata==4.11.4
Jinja2==3.1.2
MarkupSafe==2.1.1
packaging==21.3
Pygments==2.12.0
-pyparsing==3.0.8
+pyparsing==3.0.9
pytz==2022.1
-requests==2.27.1
+requests==2.28.0
snowballstemmer==2.2.0
-Sphinx==4.5.0
+Sphinx==5.0.1
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
diff --git a/misc/requirements/requirements-tests-bleeding.txt b/misc/requirements/requirements-tests-bleeding.txt
index 72d6ad083..fca7328f8 100644
--- a/misc/requirements/requirements-tests-bleeding.txt
+++ b/misc/requirements/requirements-tests-bleeding.txt
@@ -24,8 +24,6 @@ git+https://github.com/pytest-dev/pytest-cov.git
git+https://github.com/The-Compiler/pytest-xvfb.git
git+https://github.com/pytest-dev/pytest-xdist.git
git+https://github.com/john-kurkowski/tldextract
-# https://github.com/hjwp/pytest-icdiff/pull/20
-# git+https://github.com/hjwp/pytest-icdiff.git
# Problematic: needs rust (and some time to build)
# git+https://github.com/ArniDagur/python-adblock.git
diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt
index e3b0dcd4b..3e9f3233d 100644
--- a/misc/requirements/requirements-tests.txt
+++ b/misc/requirements/requirements-tests.txt
@@ -2,18 +2,20 @@
attrs==21.4.0
beautifulsoup4==4.11.1
-certifi==2021.10.8
+certifi==2022.5.18.1
charset-normalizer==2.0.12
cheroot==8.6.0
click==8.1.3
-coverage==6.3.2
+coverage==6.4.1
+exceptiongroup==1.0.0rc8
execnet==1.9.0
-filelock==3.6.0
+filelock==3.7.1
Flask==2.1.2
glob2==0.7
hunter==3.4.3
-hypothesis==6.46.2
+hypothesis==6.47.2
idna==3.3
+importlib-metadata==4.11.4
iniconfig==1.1.1
itsdangerous==2.1.2
jaraco.functools==3.5.0
@@ -29,7 +31,7 @@ pluggy==1.0.0
py==1.11.0
py-cpuinfo==8.0.0
Pygments==2.12.0
-pyparsing==3.0.8
+pyparsing==3.0.9
pytest==7.1.2
pytest-bdd==4.1.0
pytest-benchmark==3.4.1
@@ -43,7 +45,7 @@ pytest-rerunfailures==10.2
pytest-xdist==2.5.0
pytest-xvfb==2.0.0
PyVirtualDisplay==3.0
-requests==2.27.1
+requests==2.28.0
requests-file==1.5.1
six==1.16.0
sortedcontainers==2.4.0
@@ -52,5 +54,6 @@ tldextract==3.3.0
toml==0.10.2
tomli==2.0.1
urllib3==1.26.9
-vulture==2.3
+vulture==2.4
Werkzeug==2.1.2
+zipp==3.8.0
diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt
index f6c14de9f..533e91e82 100644
--- a/misc/requirements/requirements-tox.txt
+++ b/misc/requirements/requirements-tox.txt
@@ -1,14 +1,14 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
distlib==0.3.4
-filelock==3.6.0
+filelock==3.7.1
packaging==21.3
-pip==22.0.4
+pip==22.1.2
platformdirs==2.5.2
pluggy==1.0.0
py==1.11.0
-pyparsing==3.0.8
-setuptools==62.1.0
+pyparsing==3.0.9
+setuptools==62.3.4
six==1.16.0
toml==0.10.2
tox==3.25.0
diff --git a/misc/requirements/requirements-vulture.txt b/misc/requirements/requirements-vulture.txt
index 433500bf0..ac5016c99 100644
--- a/misc/requirements/requirements-vulture.txt
+++ b/misc/requirements/requirements-vulture.txt
@@ -1,4 +1,4 @@
# This file is automatically generated by scripts/dev/recompile_requirements.py
toml==0.10.2
-vulture==2.3
+vulture==2.4
diff --git a/misc/userscripts/qute-keepassxc b/misc/userscripts/qute-keepassxc
index a128c2c3e..61a6c7bce 100755
--- a/misc/userscripts/qute-keepassxc
+++ b/misc/userscripts/qute-keepassxc
@@ -43,6 +43,8 @@ config.bind('<Alt-Shift-u>', 'spawn --userscript qute-keepassxc --key ABC1234',
config.bind('pw', 'spawn --userscript qute-keepassxc --key ABC1234', mode='normal')
```
+To manage multiple accounts you also need [rofi](https://github.com/davatorium/rofi) installed.
+
# Usage
@@ -65,6 +67,26 @@ Therefore you need to have a public-key-pair readily set up.
GPG might then ask for your private-key password whenever you query the database for login credentials.
+# TOTP
+
+This script recently received experimental TOTP support.
+To use it, you need to have working TOTP authentication within KeepassXC.
+Then call `qute-keepassxc` with the `--totp` flags.
+
+For example, I have the following line in my `config.py`:
+
+```python
+config.bind('pt', 'spawn --userscript qute-keepassxc --key ABC1234 --totp', mode='normal')
+```
+
+For now this script will simply insert the TOTP-token into the currently selected
+input field, since I have not yet found a reliable way to identify the correct field
+within all existing login forms.
+Thus you need to manually select the TOTP input field, press escape to leave input
+mode and then enter `pt` to fill in the token (or configure another key-binding for
+insert mode if you prefer that).
+
+
[1]: https://keepassxc.org/
[2]: https://qutebrowser.org/
[3]: https://gnupg.org/
@@ -88,6 +110,8 @@ import nacl.public
def parse_args():
parser = argparse.ArgumentParser(description="Full passwords from KeepassXC")
parser.add_argument('url', nargs='?', default=os.environ.get('QUTE_URL'))
+ parser.add_argument('--totp', action='store_true',
+ help="Fill in current TOTP field instead of username/password")
parser.add_argument('--socket', '-s', default='/run/user/{}/org.keepassxc.KeePassXC.BrowserServer'.format(os.getuid()),
help='Path to KeepassXC browser socket')
parser.add_argument('--key', '-k', default='alice@example.com',
@@ -160,7 +184,7 @@ class KeepassXC:
action = 'test-associate',
id = self.id,
key = base64.b64encode(self.id_key.public_key.encode()).decode('utf-8')
- ))
+ ), triggerUnlock = 'true')
return self.recv_msg()['success'] == 'true'
def associate(self):
@@ -180,6 +204,16 @@ class KeepassXC:
))
return self.recv_msg()['entries']
+ def get_totp(self, uuid):
+ self.send_msg(dict(
+ action = 'get-totp',
+ uuid = uuid
+ ))
+ response = self.recv_msg()
+ if response['success'] != 'true' or not response['totp']:
+ return None
+ return response['totp']
+
def send_raw_msg(self, msg):
self.sock.send( json.dumps(msg).encode('utf-8') )
@@ -274,6 +308,30 @@ def connect_to_keepassxc(args):
return kp
+def select_account(creds):
+ try:
+ if len(creds) == 1:
+ return creds[0]
+ idx = subprocess.check_output(
+ ['rofi', '-dmenu', '-format', 'i', '-matching', 'fuzzy',
+ '-p', 'Search',
+ '-mesg', '<b>qute-keepassxc</b>: select an account, please!'],
+ input=b"\n".join(c['login'].encode('utf-8') for c in creds)
+ )
+ idx = int(idx)
+ if idx < 0:
+ return None
+ return creds[idx]
+ except subprocess.CalledProcessError:
+ return None
+ except FileNotFoundError:
+ error("rofi not found. Please install rofi to select from multiple credentials")
+ return creds[0]
+ except Exception as e:
+ error(f"Error while picking account: {e}")
+ return None
+
+
def make_js_code(username, password):
return ' '.join("""
function isVisible(elem) {
@@ -335,6 +393,21 @@ def make_js_code(username, password):
""".splitlines()) % (json.dumps(username), json.dumps(password))
+def make_js_totp_code(totp):
+ return ' '.join("""
+ (function () {
+ var input = document.activeElement;
+ if (!input || input.tagName !== "INPUT") {
+ alert("No TOTP input field selected");
+ return;
+ }
+ input.value = %s;
+ input.dispatchEvent(new Event('input', { 'bubbles': true }));
+ input.dispatchEvent(new Event('change', { 'bubbles': true }));
+ })();
+ """.splitlines()) % (json.dumps(totp),)
+
+
def main():
if 'QUTE_FIFO' not in os.environ:
print(f"No QUTE_FIFO found - {sys.argv[0]} must be run as a qutebrowser userscript")
@@ -351,10 +424,21 @@ def main():
if not creds:
error('No credentials found')
return
- # TODO: handle multiple matches
- name, pw = creds[0]['login'], creds[0]['password']
- if name and pw:
- qute('jseval -q ' + make_js_code(name, pw))
+ cred = select_account(creds)
+ if not cred:
+ error('No credentials selected')
+ return
+ if args.totp:
+ uuid = cred['uuid']
+ totp = kp.get_totp(uuid)
+ if not totp:
+ error('No TOTP key found')
+ return
+ qute('jseval -q ' + make_js_totp_code(totp))
+ else:
+ name, pw = cred['login'], cred['password']
+ if name and pw:
+ qute('jseval -q ' + make_js_code(name, pw))
except Exception as e:
error(str(e))
diff --git a/misc/userscripts/qutedmenu b/misc/userscripts/qutedmenu
index bdd0d9b27..7f326916b 100755
--- a/misc/userscripts/qutedmenu
+++ b/misc/userscripts/qutedmenu
@@ -48,6 +48,8 @@ url=${url/*http/http}
[[ -z $url ]] && exit 0
case $1 in
- open) printf '%s' "open $url" >> "$QUTE_FIFO" || qutebrowser "$url" ;;
- tab) printf '%s' "open -t $url" >> "$QUTE_FIFO" || qutebrowser "$url" ;;
+ open) printf '%s' "open $url" >> "$QUTE_FIFO" || qutebrowser "$url" ;;
+ tab) printf '%s' "open -t $url" >> "$QUTE_FIFO" || qutebrowser "$url" ;;
+ window) printf '%s' "open -w $url" >> "$QUTE_FIFO" || qutebrowser "$url --target window" ;;
+ private) printf '%s' "open -p $url" >> "$QUTE_FIFO" || qutebrowser "$url --target private-window" ;;
esac
diff --git a/misc/userscripts/readability-js b/misc/userscripts/readability-js
index 485957ddb..752b759bb 100755
--- a/misc/userscripts/readability-js
+++ b/misc/userscripts/readability-js
@@ -59,9 +59,6 @@ const HEADER = `
width: 100%;
margin: 0 0;
}
- a.reader-title {
- color: #FFFFFF !important;
- }
img {
max-width:100%;
height:auto;
diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py
index c38435242..75d4e0532 100644
--- a/qutebrowser/__init__.py
+++ b/qutebrowser/__init__.py
@@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2021 Florian Bruhin (The Compiler)"
__license__ = "GPL"
__maintainer__ = __author__
__email__ = "mail@qutebrowser.org"
-__version__ = "2.5.0"
+__version__ = "2.5.1"
__version_info__ = tuple(int(part) for part in __version__.split('.'))
__description__ = "A keyboard-driven, vim-like browser based on Python and Qt."
diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index aa4e33ced..81915e11c 100644
--- a/qutebrowser/browser/browsertab.py
+++ b/qutebrowser/browser/browsertab.py
@@ -287,6 +287,61 @@ class AbstractPrinting:
diag.open(do_print)
+@dataclasses.dataclass
+class SearchMatch:
+
+ """The currently highlighted search match.
+
+ Attributes:
+ current: The currently active search match on the page.
+ 0 if no search is active or the feature isn't available.
+ total: The total number of search matches on the page.
+ 0 if no search is active or the feature isn't available.
+ """
+
+ current: int = 0
+ total: int = 0
+
+ def reset(self) -> None:
+ """Reset match counter information.
+
+ Stale information could lead to next_result or prev_result misbehaving.
+ """
+ self.current = 0
+ self.total = 0
+
+ def is_null(self) -> bool:
+ """Whether the SearchMatch is set to zero."""
+ return self.current == 0 and self.total == 0
+
+ def at_limit(self, going_up: bool) -> bool:
+ """Whether the SearchMatch is currently at the first/last result."""
+ return (
+ self.total != 0 and
+ (
+ going_up and self.current == 1 or
+ not going_up and self.current == self.total
+ )
+ )
+
+ def __str__(self) -> str:
+ return f"{self.current}/{self.total}"
+
+
+class SearchNavigationResult(enum.Enum):
+
+ """The outcome of calling prev_/next_result."""
+
+ found = enum.auto()
+ not_found = enum.auto()
+
+ wrapped_bottom = enum.auto()
+ wrap_prevented_bottom = enum.auto()
+
+ wrapped_top = enum.auto()
+ wrap_prevented_top = enum.auto()
+
+
class AbstractSearch(QObject):
"""Attribute ``search`` of AbstractTab for doing searches.
@@ -295,17 +350,24 @@ class AbstractSearch(QObject):
text: The last thing this view was searched for.
search_displayed: Whether we're currently displaying search results in
this view.
+ match: The currently active search match.
_flags: The flags of the last search (needs to be set by subclasses).
_widget: The underlying WebView widget.
+
+ Signals:
+ finished: A search has finished. True if the text was found, false otherwise.
+ match_changed: The currently active search match has changed.
+ Emits SearchMatch(0, 0) if no search is active.
+ Will not be emitted if search matches are not available.
+ cleared: An existing search was cleared.
"""
- #: Signal emitted when a search was finished
- #: (True if the text was found, False otherwise)
finished = pyqtSignal(bool)
- #: Signal emitted when an existing search was cleared.
+ match_changed = pyqtSignal(SearchMatch)
cleared = pyqtSignal()
_Callback = Callable[[bool], None]
+ _NavCallback = Callable[[SearchNavigationResult], None]
def __init__(self, tab: 'AbstractTab', parent: QWidget = None):
super().__init__(parent)
@@ -313,6 +375,7 @@ class AbstractSearch(QObject):
self._widget = cast(_WidgetType, None)
self.text: Optional[str] = None
self.search_displayed = False
+ self.match = SearchMatch()
def _is_case_sensitive(self, ignore_case: usertypes.IgnoreCase) -> bool:
"""Check if case-sensitivity should be used.
@@ -333,7 +396,6 @@ class AbstractSearch(QObject):
def search(self, text: str, *,
ignore_case: usertypes.IgnoreCase = usertypes.IgnoreCase.never,
reverse: bool = False,
- wrap: bool = True,
result_cb: _Callback = None) -> None:
"""Find the given text on the page.
@@ -341,7 +403,6 @@ class AbstractSearch(QObject):
text: The text to search for.
ignore_case: Search case-insensitively.
reverse: Reverse search direction.
- wrap: Allow wrapping at the top or bottom of the page.
result_cb: Called with a bool indicating whether a match was found.
"""
raise NotImplementedError
@@ -350,19 +411,21 @@ class AbstractSearch(QObject):
"""Clear the current search."""
raise NotImplementedError
- def prev_result(self, *, result_cb: _Callback = None) -> None:
+ def prev_result(self, *, wrap: bool = False, callback: _NavCallback = None) -> None:
"""Go to the previous result of the current search.
Args:
- result_cb: Called with a bool indicating whether a match was found.
+ wrap: Allow wrapping at the top or bottom of the page.
+ callback: Called with a SearchNavigationResult.
"""
raise NotImplementedError
- def next_result(self, *, result_cb: _Callback = None) -> None:
+ def next_result(self, *, wrap: bool = False, callback: _NavCallback = None) -> None:
"""Go to the next result of the current search.
Args:
- result_cb: Called with a bool indicating whether a match was found.
+ wrap: Allow wrapping at the top or bottom of the page.
+ callback: Called with a SearchNavigationResult.
"""
raise NotImplementedError
diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index 4f782c3ee..e6d2af822 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -1538,33 +1538,39 @@ class CommandDispatcher:
message.error(str(e))
ed.backup()
- def _search_cb(self, found, *, tab, old_scroll_pos, options, text, prev):
- """Callback called from search/search_next/search_prev.
+ def _search_cb(self, found, *, text):
+ """Callback called from :search.
Args:
found: Whether the text was found.
- tab: The AbstractTab in which the search was made.
- old_scroll_pos: The scroll position (QPoint) before the search.
- options: The options (dict) the search was made with.
- text: The text searched for.
- prev: Whether we're searching backwards (i.e. :search-prev)
"""
- # :search/:search-next without reverse -> down
- # :search/:search-next with reverse -> up
- # :search-prev without reverse -> up
- # :search-prev with reverse -> down
- going_up = options['reverse'] ^ prev
-
- if found:
- # Check if the scroll position got smaller and show info.
- if not going_up and tab.scroller.pos_px().y() < old_scroll_pos.y():
- message.info("Search hit BOTTOM, continuing at TOP")
- elif going_up and tab.scroller.pos_px().y() > old_scroll_pos.y():
- message.info("Search hit TOP, continuing at BOTTOM")
- else:
+ if not found:
message.warning(f"Text '{text}' not found on page!",
replace='find-in-page')
+ def _search_navigation_cb(self, result):
+ """Callback called from :search-prev/next."""
+ if result == browsertab.SearchNavigationResult.not_found:
+ # FIXME check if this actually can happen...
+ message.warning("Search result vanished...")
+ return
+ elif result == browsertab.SearchNavigationResult.found:
+ return
+ elif not config.val.search.wrap_messages:
+ return
+
+ messages = {
+ browsertab.SearchNavigationResult.wrap_prevented_bottom:
+ "Search hit BOTTOM",
+ browsertab.SearchNavigationResult.wrap_prevented_top:
+ "Search hit TOP",
+ browsertab.SearchNavigationResult.wrapped_bottom:
+ "Search hit BOTTOM, continuing at TOP",
+ browsertab.SearchNavigationResult.wrapped_top:
+ "Search hit TOP, continuing at BOTTOM",
+ }
+ message.info(messages[result], replace="search-hit-msg")
+
@cmdutils.register(instance='command-dispatcher', scope='window',
maxsplit=0)
def search(self, text="", reverse=False):
@@ -1584,29 +1590,18 @@ class CommandDispatcher:
options = {
'ignore_case': config.val.search.ignore_case,
'reverse': reverse,
- 'wrap': config.val.search.wrap,
}
self._tabbed_browser.search_text = text
- self._tabbed_browser.search_options = dict(options)
-
- cb = functools.partial(self._search_cb, tab=tab,
- old_scroll_pos=tab.scroller.pos_px(),
- options=options, text=text, prev=False)
- options['result_cb'] = cb
+ self._tabbed_browser.search_options = options
tab.scroller.before_jump_requested.emit()
- tab.search.search(text, **options)
- @cmdutils.register(instance='command-dispatcher', scope='window')
- @cmdutils.argument('count', value=cmdutils.Value.count)
- def search_next(self, count=1):
- """Continue the search to the ([count]th) next term.
+ cb = functools.partial(self._search_cb, text=text)
+ tab.search.search(text, **options, result_cb=cb)
- Args:
- count: How many elements to ignore.
- """
- tab = self._current_widget()
+ def _search_prev_next(self, count, tab, method):
+ """Continue the search to the prev/next term."""
window_text = self._tabbed_browser.search_text
window_options = self._tabbed_browser.search_options
@@ -1623,48 +1618,33 @@ class CommandDispatcher:
if count == 0:
return
- cb = functools.partial(self._search_cb, tab=tab,
- old_scroll_pos=tab.scroller.pos_px(),
- options=window_options, text=window_text,
- prev=False)
+ wrap = config.val.search.wrap
for _ in range(count - 1):
- tab.search.next_result()
- tab.search.next_result(result_cb=cb)
+ method(wrap=wrap)
+ method(callback=self._search_navigation_cb, wrap=wrap)
@cmdutils.register(instance='command-dispatcher', scope='window')
@cmdutils.argument('count', value=cmdutils.Value.count)
- def search_prev(self, count=1):
- """Continue the search to the ([count]th) previous term.
+ def search_next(self, count=1):
+ """Continue the search to the ([count]th) next term.
Args:
count: How many elements to ignore.
"""
tab = self._current_widget()
- window_text = self._tabbed_browser.search_text
- window_options = self._tabbed_browser.search_options
+ self._search_prev_next(count, tab, tab.search.next_result)
- if window_text is None:
- raise cmdutils.CommandError("No search done yet.")
-
- tab.scroller.before_jump_requested.emit()
-
- if window_text is not None and window_text != tab.search.text:
- tab.search.clear()
- tab.search.search(window_text, **window_options)
- count -= 1
-
- if count == 0:
- return
-
- cb = functools.partial(self._search_cb, tab=tab,
- old_scroll_pos=tab.scroller.pos_px(),
- options=window_options, text=window_text,
- prev=True)
+ @cmdutils.register(instance='command-dispatcher', scope='window')
+ @cmdutils.argument('count', value=cmdutils.Value.count)
+ def search_prev(self, count=1):
+ """Continue the search to the ([count]th) previous term.
- for _ in range(count - 1):
- tab.search.prev_result()
- tab.search.prev_result(result_cb=cb)
+ Args:
+ count: How many elements to ignore.
+ """
+ tab = self._current_widget()
+ self._search_prev_next(count, tab, tab.search.prev_result)
def _jseval_cb(self, out):
"""Show the data returned from JS."""
diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py
index 5908890ba..0eafa0536 100644
--- a/qutebrowser/browser/inspector.py
+++ b/qutebrowser/browser/inspector.py
@@ -29,7 +29,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QEvent
from PyQt5.QtGui import QCloseEvent
from qutebrowser.browser import eventfilter
-from qutebrowser.config import configfiles
+from qutebrowser.config import configfiles, config
from qutebrowser.utils import log, usertypes
from qutebrowser.keyinput import modeman
from qutebrowser.misc import miscwidgets
@@ -137,7 +137,8 @@ class AbstractWebInspector(QWidget):
@pyqtSlot()
def _on_clicked(self) -> None:
"""Enter insert mode if a docked inspector was clicked."""
- if self._position != Position.window:
+ if (self._position != Position.window and
+ config.val.input.insert_mode.auto_enter):
modeman.enter(self._win_id, usertypes.KeyMode.insert,
reason='Inspector clicked', only_if_normal=True)
diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py
index 3ea323d96..54c466415 100644
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -405,7 +405,7 @@ class FileSelectionMode(enum.Enum):
def choose_file(qb_mode: FileSelectionMode) -> List[str]:
- """Select file(s)/folder for uploading, using external command defined in config.
+ """Select file(s)/folder for up-/downloading, using an external command.
Args:
qb_mode: File selection mode
@@ -451,7 +451,7 @@ def _execute_fileselect_command(
"""Execute external command to choose file.
Args:
- multiple: Should selecting multiple files be allowed.
+ qb_mode: Should selecting multiple files be allowed.
tmpfilename: Path to the temporary file if used, otherwise None.
Return:
diff --git a/qutebrowser/browser/webengine/notification.py b/qutebrowser/browser/webengine/notification.py
index e4ad4d763..3571bb24d 100644
--- a/qutebrowser/browser/webengine/notification.py
+++ b/qutebrowser/browser/webengine/notification.py
@@ -621,18 +621,19 @@ class HerbeNotificationAdapter(AbstractNotificationAdapter):
so there's no point.
"""
if status == QProcess.CrashExit:
- return
-
- if code == 0:
+ pass
+ elif code == 0:
self.click_id.emit(pid)
elif code == 2:
- self.close_id.emit(pid)
+ pass
else:
proc = self.sender()
assert isinstance(proc, QProcess), proc
stderr = proc.readAllStandardError()
raise Error(f'herbe exited with status {code}: {stderr}')
+ self.close_id.emit(pid)
+
@pyqtSlot(QProcess.ProcessError)
def _on_error(self, error: QProcess.ProcessError) -> None:
if error == QProcess.Crashed:
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index 319865150..8057d5800 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -93,96 +93,45 @@ class WebEnginePrinting(browsertab.AbstractPrinting):
def to_pdf(self, filename):
self._widget.page().printToPdf(filename)
- def to_printer(self, printer, callback=None):
- if callback is None:
- callback = lambda _ok: None
+ def to_printer(self, printer, callback=lambda ok: None):
self._widget.page().print(printer, callback)
-class _WebEngineSearchWrapHandler:
-
- """QtWebEngine implementations related to wrapping when searching.
-
- Attributes:
- flag_wrap: An additional flag indicating whether the last search
- used wrapping.
- _active_match: The 1-based index of the currently active match
- on the page.
- _total_matches: The total number of search matches on the page.
- _nowrap_available: Whether the functionality to prevent wrapping
- is available.
- """
-
- def __init__(self):
- self._active_match = 0
- self._total_matches = 0
- self.flag_wrap = True
- self._nowrap_available = False
-
- def connect_signal(self, page):
- """Connect to the findTextFinished signal of the page.
-
- Args:
- page: The QtWebEnginePage to connect to this handler.
- """
- if not qtutils.version_check("5.14"):
- return
-
- try:
- # pylint: disable=unused-import
- from PyQt5.QtWebEngineCore import QWebEngineFindTextResult
- except ImportError:
- # WORKAROUND for some odd PyQt/packaging bug where the
- # findTextResult signal is available, but QWebEngineFindTextResult
- # is not. Seems to happen on e.g. Gentoo.
- log.webview.warning("Could not import QWebEngineFindTextResult "
- "despite running on Qt 5.14. You might need "
- "to rebuild PyQtWebEngine.")
- return
-
- page.findTextFinished.connect(self._store_match_data)
- self._nowrap_available = True
-
- def _store_match_data(self, result):
- """Store information on the last match.
-
- The information will be checked against when wrapping is turned off.
-
- Args:
- result: A FindTextResult passed by the findTextFinished signal.
- """
- self._active_match = result.activeMatch()
- self._total_matches = result.numberOfMatches()
- log.webview.debug("Active search match: {}/{}"
- .format(self._active_match, self._total_matches))
-
- def reset_match_data(self):
- """Reset match information.
-
- Stale information could lead to next_result or prev_result misbehaving.
- """
- self._active_match = 0
- self._total_matches = 0
+@dataclasses.dataclass
+class _FindFlags:
+
+ case_sensitive: bool = False
+ backward: bool = False
+
+ def to_qt(self):
+ """Convert flags into Qt flags."""
+ # FIXME:mypy Those should be correct, reevaluate with PyQt6-stubs
+ flags = QWebEnginePage.FindFlag(0)
+ if self.case_sensitive:
+ flags |= ( # type: ignore[assignment]
+ QWebEnginePage.FindFlag.FindCaseSensitively)
+ if self.backward:
+ flags |= QWebEnginePage.FindFlag.FindBackward # type: ignore[assignment]
+ return flags
- def prevent_wrapping(self, *, going_up):
- """Prevent wrapping if possible and required.
+ def __bool__(self):
+ """Flags are truthy if any flag is set to True."""
+ return any(dataclasses.astuple(self))
- Returns True if a wrap was prevented and False if not.
+ def __str__(self):
+ """List all true flags, in Qt enum style.
- Args:
- going_up: Whether the search would scroll the page up or down.
+ This needs to be in the same format as QtWebKit, for tests.
"""
- if (not self._nowrap_available or
- self.flag_wrap or self._total_matches == 0):
- return False
- elif going_up and self._active_match == 1:
- message.info("Search hit TOP")
- return True
- elif not going_up and self._active_match == self._total_matches:
- message.info("Search hit BOTTOM")
- return True
- else:
- return False
+ names = {
+ "case_sensitive": "FindCaseSensitively",
+ "backward": "FindBackward",
+ }
+ d = dataclasses.asdict(self)
+ truthy = [names[key] for key, value in d.items() if value]
+ if not truthy:
+ return "<no find flags>"
+ return "|".join(truthy)
class WebEngineSearch(browsertab.AbstractSearch):
@@ -190,7 +139,7 @@ class WebEngineSearch(browsertab.AbstractSearch):
"""QtWebEngine implementations related to searching on the page.
Attributes:
- _flags: The QWebEnginePage.FindFlags of the last search.
+ _flags: The FindFlags of the last search.
_pending_searches: How many searches have been started but not called
back yet.
"""
@@ -199,24 +148,34 @@ class WebEngineSearch(browsertab.AbstractSearch):
def __init__(self, tab, parent=None):
super().__init__(tab, parent)
- self._flags = self._empty_flags()
+ self._flags = _FindFlags()
self._pending_searches = 0
- # The API necessary to stop wrapping was added in this version
- self._wrap_handler = _WebEngineSearchWrapHandler()
+ self.match = browsertab.SearchMatch()
+ self._old_match = browsertab.SearchMatch()
- def _empty_flags(self):
- return QWebEnginePage.FindFlags(0)
-
- def _args_to_flags(self, reverse, ignore_case):
- flags = self._empty_flags()
- if self._is_case_sensitive(ignore_case):
- flags |= QWebEnginePage.FindCaseSensitively
- if reverse:
- flags |= QWebEnginePage.FindBackward
- return flags
+ def _store_flags(self, reverse, ignore_case):
+ self._flags.case_sensitive = self._is_case_sensitive(ignore_case)
+ self._flags.backward = reverse
def connect_signals(self):
- self._wrap_handler.connect_signal(self._widget.page())
+ """Connect the signals necessary for this class to function."""
+ # The API necessary to stop wrapping was added in this version
+ if not qtutils.version_check("5.14"):
+ return
+
+ try:
+ # pylint: disable=unused-import
+ from PyQt5.QtWebEngineCore import QWebEngineFindTextResult
+ except ImportError:
+ # WORKAROUND for some odd PyQt/packaging bug where the
+ # findTextResult signal is available, but QWebEngineFindTextResult
+ # is not. Seems to happen on e.g. Gentoo.
+ log.webview.warning("Could not import QWebEngineFindTextResult "
+ "despite running on Qt 5.14. You might need "
+ "to rebuild PyQtWebEngine.")
+ return
+
+ self._widget.page().findTextFinished.connect(self._on_find_finished)
def _find(self, text, flags, callback, caller):
"""Call findText on the widget."""
@@ -243,8 +202,7 @@ class WebEngineSearch(browsertab.AbstractSearch):
found_text = 'found' if found else "didn't find"
if flags:
- flag_text = 'with flags {}'.format(debug.qflags_key(
- QWebEnginePage, flags, klass=QWebEnginePage.FindFlag))
+ flag_text = f'with flags {flags}'
else:
flag_text = ''
log.webview.debug(' '.join([caller, found_text, text, flag_text])
@@ -252,51 +210,88 @@ class WebEngineSearch(browsertab.AbstractSearch):
if callback is not None:
callback(found)
+
self.finished.emit(found)
- self._widget.page().findText(text, flags, wrapped_callback)
+ self._widget.page().findText(text, flags.to_qt(), wrapped_callback)
+
+ def _on_find_finished(self, find_text_result):
+ """Unwrap the result, store it, and pass it along."""
+ self._old_match = self.match
+ self.match = browsertab.SearchMatch(
+ current=find_text_result.activeMatch(),
+ total=find_text_result.numberOfMatches(),
+ )
+ log.webview.debug(f"Active search match: {self.match}")
+ self.match_changed.emit(self.match)
def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
- reverse=False, wrap=True, result_cb=None):
+ 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 {}, but resetting flags".format(text))
- self._flags = self._args_to_flags(reverse, ignore_case)
+ self._store_flags(reverse, ignore_case)
return
self.text = text
- self._flags = self._args_to_flags(reverse, ignore_case)
- self._wrap_handler.reset_match_data()
- self._wrap_handler.flag_wrap = wrap
+ self._store_flags(reverse, ignore_case)
+ self.match.reset()
self._find(text, self._flags, result_cb, 'search')
def clear(self):
if self.search_displayed:
self.cleared.emit()
+ self.match_changed.emit(browsertab.SearchMatch())
self.search_displayed = False
- self._wrap_handler.reset_match_data()
+ self.match.reset()
self._widget.page().findText('')
- def prev_result(self, *, result_cb=None):
- # The int() here makes sure we get a copy of the flags.
- flags = QWebEnginePage.FindFlags(int(self._flags))
- if flags & QWebEnginePage.FindBackward:
- if self._wrap_handler.prevent_wrapping(going_up=False):
- return
- flags &= ~QWebEnginePage.FindBackward
+ def _prev_next_cb(self, found, *, going_up, callback):
+ """Call the prev/next callback based on the search result."""
+ if found:
+ result = browsertab.SearchNavigationResult.found
+ # Check if the match count change is opposite to the search direction
+ if self._old_match.current > 0:
+ if not going_up and self._old_match.current > self.match.current:
+ result = browsertab.SearchNavigationResult.wrapped_bottom
+ elif going_up and self._old_match.current < self.match.current:
+ result = browsertab.SearchNavigationResult.wrapped_top
else:
- if self._wrap_handler.prevent_wrapping(going_up=True):
- return
- flags |= QWebEnginePage.FindBackward
- self._find(self.text, flags, result_cb, 'prev_result')
+ result = browsertab.SearchNavigationResult.not_found
+
+ callback(result)
- def next_result(self, *, result_cb=None):
- going_up = self._flags & QWebEnginePage.FindBackward
- if self._wrap_handler.prevent_wrapping(going_up=going_up):
+ def prev_result(self, *, wrap=False, callback=None):
+ going_up = not self._flags.backward
+ flags = dataclasses.replace(self._flags, backward=going_up)
+
+ if self.match.at_limit(going_up=going_up) and not wrap:
+ res = (
+ browsertab.SearchNavigationResult.wrap_prevented_top if going_up else
+ browsertab.SearchNavigationResult.wrap_prevented_bottom
+ )
+ if callback is not None:
+ callback(res)
return
- self._find(self.text, self._flags, result_cb, 'next_result')
+
+ cb = functools.partial(self._prev_next_cb, going_up=going_up, callback=callback)
+ self._find(self.text, flags, cb, 'prev_result')
+
+ def next_result(self, *, wrap=False, callback=None):
+ going_up = self._flags.backward
+ if self.match.at_limit(going_up=going_up) and not wrap:
+ res = (
+ browsertab.SearchNavigationResult.wrap_prevented_top if going_up else
+ browsertab.SearchNavigationResult.wrap_prevented_bottom
+ )
+ if callback is not None:
+ callback(res)
+ return
+
+ cb = functools.partial(self._prev_next_cb, going_up=going_up, callback=callback)
+ self._find(self.text, self._flags, cb, 'next_result')
class WebEngineCaret(browsertab.AbstractCaret):
diff --git a/qutebrowser/browser/webkit/http.py b/qutebrowser/browser/webkit/http.py
index d13471277..a38cd358a 100644
--- a/qutebrowser/browser/webkit/http.py
+++ b/qutebrowser/browser/webkit/http.py
@@ -89,13 +89,16 @@ class ContentDisposition:
try:
parsed = reg('Content-Disposition', decoded)
except IndexError: # pragma: no cover
- # WORKAROUND for https://bugs.python.org/issue37491
+ # WORKAROUND for https://github.com/python/cpython/issues/81672
# Fixed in Python 3.7.5 and 3.8.0.
# Still getting failures on 3.10 on CI though
raise ContentDispositionError("Missing closing quote character")
except ValueError: # pragma: no cover
- # WORKAROUND for https://bugs.python.org/issue42946
+ # WORKAROUND for https://github.com/python/cpython/issues/87112
raise ContentDispositionError("Non-ASCII digit")
+ except AttributeError:
+ # WORKAROUND for https://github.com/python/cpython/issues/93010
+ raise ContentDispositionError("Section number has an invalid leading 0")
if parsed.defects:
defects = list(parsed.defects)
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 8d4618eda..0a1ac18f2 100644
--- a/qutebrowser/browser/webkit/webkittab.py
+++ b/qutebrowser/browser/webkit/webkittab.py
@@ -115,14 +115,12 @@ class WebKitSearch(browsertab.AbstractSearch):
def _empty_flags(self):
return QWebPage.FindFlags(0) # type: ignore[call-overload]
- def _args_to_flags(self, reverse, ignore_case, wrap):
+ def _args_to_flags(self, reverse, ignore_case):
flags = self._empty_flags()
if self._is_case_sensitive(ignore_case):
flags |= QWebPage.FindCaseSensitively
if reverse:
flags |= QWebPage.FindBackward
- if wrap:
- flags |= QWebPage.FindWrapsAroundDocument
return flags
def _call_cb(self, callback, found, text, flags, caller):
@@ -150,7 +148,19 @@ class WebKitSearch(browsertab.AbstractSearch):
log.webview.debug(' '.join([caller, found_text, text, flag_text])
.strip())
if callback is not None:
- QTimer.singleShot(0, functools.partial(callback, found))
+ if caller in ["prev_result", "next_result"]:
+ if found:
+ # no wrapping detection
+ cb_value = browsertab.SearchNavigationResult.found
+ elif flags & QWebPage.FindBackward:
+ cb_value = browsertab.SearchNavigationResult.wrap_prevented_top
+ else:
+ cb_value = browsertab.SearchNavigationResult.wrap_prevented_bottom
+ elif caller == "search":
+ cb_value = found
+ else:
+ raise utils.Unreachable(caller)
+ QTimer.singleShot(0, functools.partial(callback, cb_value))
self.finished.emit(found)
@@ -164,12 +174,12 @@ class WebKitSearch(browsertab.AbstractSearch):
'', QWebPage.HighlightAllOccurrences) # type: ignore[arg-type]
def search(self, text, *, ignore_case=usertypes.IgnoreCase.never,
- reverse=False, wrap=True, result_cb=None):
+ 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 {}, but resetting flags".format(text))
- self._flags = self._args_to_flags(reverse, ignore_case, wrap)
+ self._flags = self._args_to_flags(reverse, ignore_case)
return
# Clear old search results, this is done automatically on QtWebEngine.
@@ -177,7 +187,7 @@ class WebKitSearch(browsertab.AbstractSearch):
self.text = text
self.search_displayed = True
- self._flags = self._args_to_flags(reverse, ignore_case, wrap)
+ self._flags = self._args_to_flags(reverse, ignore_case)
# We actually search *twice* - once to highlight everything, then again
# to get a mark so we can navigate.
found = self._widget.findText(text, self._flags)
@@ -185,22 +195,34 @@ class WebKitSearch(browsertab.AbstractSearch):
self._flags | QWebPage.HighlightAllOccurrences)
self._call_cb(result_cb, found, text, self._flags, 'search')
- def next_result(self, *, result_cb=None):
+ def next_result(self, *, wrap=False, callback=None):
self.search_displayed = True
- found = self._widget.findText(self.text, self._flags) # type: ignore[arg-type]
- self._call_cb(result_cb, found, self.text, self._flags, 'next_result')
+ # The int() here makes sure we get a copy of the flags.
+ flags = QWebPage.FindFlags(
+ int(self._flags)) # type: ignore[call-overload]
- def prev_result(self, *, result_cb=None):
+ if wrap:
+ flags |= QWebPage.FindWrapsAroundDocument
+
+ found = self._widget.findText(self.text, flags) # type: ignore[arg-type]
+ self._call_cb(callback, found, self.text, flags, 'next_result')
+
+ def prev_result(self, *, wrap=False, callback=None):
self.search_displayed = True
# The int() here makes sure we get a copy of the flags.
flags = QWebPage.FindFlags(
int(self._flags)) # type: ignore[call-overload]
+
if flags & QWebPage.FindBackward:
flags &= ~QWebPage.FindBackward
else:
flags |= QWebPage.FindBackward
+
+ if wrap:
+ flags |= QWebPage.FindWrapsAroundDocument
+
found = self._widget.findText(self.text, flags) # type: ignore[arg-type]
- self._call_cb(result_cb, found, self.text, flags, 'prev_result')
+ self._call_cb(callback, found, self.text, flags, 'prev_result')
class WebKitCaret(browsertab.AbstractCaret):
diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py
index b94077c6d..cf6984288 100644
--- a/qutebrowser/completion/completer.py
+++ b/qutebrowser/completion/completer.py
@@ -40,7 +40,8 @@ class CompletionInfo:
"""Context passed into all completion functions."""
config: config.Config
- keyconf: config.KeyConfig # pylint: disable=used-before-assignment
+ # pylint: disable-next=used-before-assignment
+ keyconf: config.KeyConfig # type: ignore[name-defined]
win_id: int
cur_tab: 'browsertab.AbstractTab'
@@ -164,6 +165,7 @@ class Completer(QObject):
# cursor is in a space between two existing words
parts.insert(i, '')
prefix = [x.strip() for x in parts[:i]]
+ # pylint: disable-next=unnecessary-list-index-lookup
center = parts[i].strip()
# strip trailing whitespace included as a separate token
postfix = [x.strip() for x in parts[i+1:] if not x.isspace()]
diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py
index 7c8473b3f..6c85fbb29 100644
--- a/qutebrowser/completion/models/configmodel.py
+++ b/qutebrowser/completion/models/configmodel.py
@@ -44,16 +44,22 @@ def customized_option(*, info):
def list_option(*, info):
"""A CompletionModel filled with settings whose values are lists."""
- predicate = lambda opt: (isinstance(info.config.get_obj(opt.name),
- list) and not opt.no_autoconfig)
- return _option(info, "List options", predicate)
+ return _option(
+ info,
+ "List options",
+ lambda opt: (isinstance(info.config.get_obj(opt.name), list) and
+ not opt.no_autoconfig)
+ )
def dict_option(*, info):
"""A CompletionModel filled with settings whose values are dicts."""
- predicate = lambda opt: (isinstance(info.config.get_obj(opt.name),
- dict) and not opt.no_autoconfig)
- return _option(info, "Dict options", predicate)
+ return _option(
+ info,
+ "Dict options",
+ lambda opt: (isinstance(info.config.get_obj(opt.name), dict) and
+ not opt.no_autoconfig)
+ )
def _option(info, title, predicate):
diff --git a/qutebrowser/components/misccommands.py b/qutebrowser/components/misccommands.py
index c20e8e290..77b8a8948 100644
--- a/qutebrowser/components/misccommands.py
+++ b/qutebrowser/components/misccommands.py
@@ -27,7 +27,7 @@ import signal
import functools
import logging
import pathlib
-from typing import Optional
+from typing import Optional, Sequence, Callable
try:
import hunter
@@ -223,13 +223,33 @@ def insert_text(tab: apitypes.Tab, text: str) -> None:
tab.elements.find_focused(_insert_text_cb)
+def _wrap_find_at_pos(value: str, tab: apitypes.Tab,
+ callback: Callable[[Optional[apitypes.WebElement]], None]
+ ) -> None:
+ try:
+ point = utils.parse_point(value)
+ except ValueError as e:
+ message.error(str(e))
+ return
+ tab.elements.find_at_pos(point, callback)
+
+
+_FILTER_ERRORS = {
+ 'id': lambda x: f'with ID "{x}"',
+ 'css': lambda x: f'matching CSS selector "{x}"',
+ 'focused': lambda _: 'with focus',
+ 'position': lambda x: 'at position {x}',
+}
+
+
@cmdutils.register()
@cmdutils.argument('tab', value=cmdutils.Value.cur_tab)
-@cmdutils.argument('filter_', choices=['id'])
-def click_element(tab: apitypes.Tab, filter_: str, value: str, *,
+@cmdutils.argument('filter_', choices=['id', 'css', 'position', 'focused'])
+def click_element(tab: apitypes.Tab, filter_: str, value: str = None, *, # noqa: C901
target: apitypes.ClickTarget =
apitypes.ClickTarget.normal,
- force_event: bool = False) -> None:
+ force_event: bool = False,
+ select_first: bool = False) -> None:
"""Click the element matching the given filter.
The given filter needs to result in exactly one element, otherwise, an
@@ -237,27 +257,63 @@ def click_element(tab: apitypes.Tab, filter_: str, value: str, *,
Args:
filter_: How to filter the elements.
- id: Get an element based on its ID.
- value: The value to filter for.
+
+ - id: Get an element based on its ID.
+ - css: Filter by a CSS selector.
+ - position: Click the element at specified position.
+ Specify `value` as 'x,y'.
+ - focused: Click the currently focused element.
+ value: The value to filter for. Optional for 'focused' filter.
target: How to open the clicked element (normal/tab/tab-bg/window).
force_event: Force generating a fake click event.
+ select_first: Select first matching element if there are multiple.
"""
- def single_cb(elem: Optional[apitypes.WebElement]) -> None:
- """Click a single element."""
- if elem is None:
- message.error("No element found with id {}!".format(value))
- return
+ def do_click(elem: apitypes.WebElement) -> None:
try:
elem.click(target, force_event=force_event)
except apitypes.WebElemError as e:
message.error(str(e))
+
+ def single_cb(elem: Optional[apitypes.WebElement]) -> None:
+ """Click a single element."""
+ if elem is None:
+ message.error(f"No element found {_FILTER_ERRORS[filter_](value)}!")
return
- handlers = {
- 'id': (tab.elements.find_id, single_cb),
- }
- handler, callback = handlers[filter_]
- handler(value, callback)
+ do_click(elem)
+
+ def multiple_cb(elems: Sequence[apitypes.WebElement]) -> None:
+ if not elems:
+ message.error(f"No element found {_FILTER_ERRORS[filter_](value)}!")
+ return
+
+ if not select_first and len(elems) > 1:
+ message.error(f"Multiple elements found {_FILTER_ERRORS[filter_](value)}!")
+ return
+
+ do_click(elems[0])
+
+ if value is None and filter_ != 'focused':
+ raise cmdutils.CommandError("Argument 'value' is only "
+ "optional with filter 'focused'!")
+
+ if filter_ == "id":
+ assert value is not None
+ tab.elements.find_id(elem_id=value, callback=single_cb)
+ elif filter_ == "css":
+ assert value is not None
+ tab.elements.find_css(
+ value,
+ callback=multiple_cb,
+ error_cb=lambda exc: message.error(str(exc)),
+ )
+ elif filter_ == "position":
+ assert value is not None
+ _wrap_find_at_pos(value, tab=tab, callback=single_cb)
+ elif filter_ == "focused":
+ tab.elements.find_focused(callback=single_cb)
+ else:
+ raise utils.Unreachable(filter_)
@cmdutils.register(debug=True)
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 2ccb520b3..4da003b37 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -72,6 +72,13 @@ search.wrap:
Wrap around at the top and bottom of the page when advancing through text
matches using `:search-next` and `:search-prev`.
+search.wrap_messages:
+ type: Bool
+ default: true
+ desc: >-
+ Display messages when advancing through text matches at the top and bottom
+ of the page, e.g. `Search hit TOP`.
+
new_instance_open_target:
type:
name: String
@@ -1685,6 +1692,7 @@ hints.selectors:
- '[role="menuitem"]'
- '[role="menuitemcheckbox"]'
- '[role="menuitemradio"]'
+ - '[role="treeitem"]'
- '[ng-click]'
- '[ngClick]'
- '[data-ng-click]'
@@ -2057,12 +2065,17 @@ statusbar.widgets:
- scroll_raw: "Raw percentage of the current page position like `10`."
- history: "Display an arrow when possible to go back/forward in
history."
+ - search_match: "A match count when searching, e.g. `Match [2/10]`."
- tabs: "Current active tab, e.g. `2`."
- keypress: "Display pressed keys when composing a vi command."
- progress: "Progress bar for the current page loading."
- 'text:foo': "Display the static text after the colon, `foo` in the example."
+ - clock: "Display current time. The format can be changed by adding a
+ format string via `clock:...`. For supported format strings, see
+ https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes[the
+ Python datetime documentation]."
none_ok: true
- default: ['keypress', 'url', 'scroll', 'history', 'tabs', 'progress']
+ default: ['keypress', 'search_match', 'url', 'scroll', 'history', 'tabs', 'progress']
desc: "List of widgets displayed in the statusbar."
## tabs
@@ -3788,6 +3801,7 @@ bindings.default:
<Down>: prompt-item-focus next
<Alt-Y>: prompt-yank
<Alt-Shift-Y>: prompt-yank --sel
+ <Alt-E>: prompt-fileselect-external
<Ctrl-B>: rl-backward-char
<Ctrl-F>: rl-forward-char
<Alt-B>: rl-backward-word
diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py
index d3d5e3fb8..eef43ded4 100644
--- a/qutebrowser/config/configtypes.py
+++ b/qutebrowser/config/configtypes.py
@@ -109,6 +109,8 @@ class ValidValues:
values: A list with the allowed untransformed values.
descriptions: A dict with value/desc mappings.
generate_docs: Whether to show the values in the docs.
+ others_permitted: Whether arbitrary values are permitted.
+ Used to show buttons in qute://settings.
"""
def __init__(
@@ -119,12 +121,14 @@ class ValidValues:
Tuple[str, Optional[str]],
],
generate_docs: bool = True,
+ others_permitted: bool = False
) -> None:
if not values:
raise ValueError("ValidValues with no values makes no sense!")
self.descriptions: DictType[str, str] = {}
self.values: ListType[str] = []
self.generate_docs = generate_docs
+ self.others_permitted = others_permitted
for value in values:
if isinstance(value, str):
# Value without description
@@ -1638,7 +1642,9 @@ class Proxy(BaseType):
super().__init__(none_ok=none_ok, completions=completions)
self.valid_values = ValidValues(
('system', "Use the system wide proxy."),
- ('none', "Don't use any proxy"))
+ ('none', "Don't use any proxy"),
+ others_permitted=True,
+ )
def to_py(
self,
@@ -2009,6 +2015,6 @@ class StatusbarWidget(String):
"""
def _validate_valid_values(self, value: str) -> None:
- if value.startswith("text:"):
+ if value.startswith("text:") or value.startswith("clock:"):
return
super()._validate_valid_values(value)
diff --git a/qutebrowser/html/settings.html b/qutebrowser/html/settings.html
index b06917fd5..f89aaa610 100644
--- a/qutebrowser/html/settings.html
+++ b/qutebrowser/html/settings.html
@@ -179,9 +179,10 @@ summary::selection {
</div>
{% endif %}
</td>
- {% if option.typ.valid_values is not none %}
+ {% set valid_values = option.typ.valid_values %}
+ {% if valid_values is not none and not valid_values.others_permitted %}
<td class="valid-value">
- {% for value in option.typ.valid_values.values %}
+ {% for value in valid_values.values %}
<div class="radio-button">
<input type="radio" id="input-{{ option.name }}-{{ loop.index0 }}"
name="{{ option.name }}" value="{{ value }}"
diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py
index b247da632..22245d8c1 100644
--- a/qutebrowser/mainwindow/mainwindow.py
+++ b/qutebrowser/mainwindow/mainwindow.py
@@ -537,6 +537,9 @@ class MainWindow(QWidget):
self.tabbed_browser.cur_load_status_changed.connect(
self.status.url.on_load_status_changed)
+ self.tabbed_browser.cur_search_match_changed.connect(
+ self.status.search_match.set_match)
+
self.tabbed_browser.cur_caret_selection_toggled.connect(
self.status.on_caret_selection_toggled)
diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py
index f7a04bee0..5d3bced59 100644
--- a/qutebrowser/mainwindow/prompt.py
+++ b/qutebrowser/mainwindow/prompt.py
@@ -467,6 +467,31 @@ class PromptContainer(QWidget):
utils.set_clipboard(question.url, sel)
message.info("Yanked to {}: {}".format(target, question.url))
+ @cmdutils.register(
+ instance='prompt-container', scope='window',
+ modes=[usertypes.KeyMode.prompt])
+ def prompt_fileselect_external(self):
+ """Choose a location using a configured external picker.
+
+ This spawns the external fileselector configured via
+ `fileselect.folder.command`.
+ """
+ assert self._prompt is not None
+ if not isinstance(self._prompt, FilenamePrompt):
+ raise cmdutils.CommandError(
+ "Can only launch external fileselect for FilenamePrompt, "
+ f"not {self._prompt.__class__.__name__}"
+ )
+ # XXX to avoid current cyclic import
+ from qutebrowser.browser import shared
+ folders = shared.choose_file(shared.FileSelectionMode.folder)
+ if not folders:
+ message.info("No folder chosen.")
+ return
+ # choose_file already checks that this is max one folder
+ assert len(folders) == 1
+ self.prompt_accept(folders[0])
+
class LineEdit(QLineEdit):
@@ -835,6 +860,7 @@ class DownloadFilenamePrompt(FilenamePrompt):
('prompt-open-download', "Open download"),
('prompt-open-download --pdfjs', "Open download via PDF.js"),
('prompt-yank', "Yank URL"),
+ ('prompt-fileselect-external', "Launch external file selector"),
]
return cmds
diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py
index 46cf083bd..e2b6e5786 100644
--- a/qutebrowser/mainwindow/statusbar/bar.py
+++ b/qutebrowser/mainwindow/statusbar/bar.py
@@ -31,7 +31,7 @@ from qutebrowser.keyinput import modeman
from qutebrowser.utils import usertypes, log, objreg, utils
from qutebrowser.mainwindow.statusbar import (backforward, command, progress,
keystring, percentage, url,
- tabindex, textbase)
+ tabindex, textbase, clock, searchmatch)
@dataclasses.dataclass
@@ -143,6 +143,7 @@ class StatusBar(QWidget):
url: The UrlText widget in the statusbar.
prog: The Progress widget in the statusbar.
cmd: The Command widget in the statusbar.
+ search_match: The SearchMatch widget in the statusbar.
_hbox: The main QHBoxLayout.
_stack: The QStackedLayout with cmd/txt widgets.
_win_id: The window ID the statusbar is associated with.
@@ -193,12 +194,15 @@ class StatusBar(QWidget):
self.cmd.hide_cmd.connect(self._hide_cmd_widget)
self._hide_cmd_widget()
+ self.search_match = searchmatch.SearchMatch()
+
self.url = url.UrlText()
self.percentage = percentage.Percentage()
self.backforward = backforward.Backforward()
self.tabindex = tabindex.TabIndex()
self.keystring = keystring.KeyString()
self.prog = progress.Progress(self)
+ self.clock = clock.Clock()
self._text_widgets = []
self._draw_widgets()
@@ -208,6 +212,33 @@ class StatusBar(QWidget):
def __repr__(self):
return utils.get_repr(self)
+ def _get_widget_from_config(self, key):
+ """Return the widget that fits with config string key."""
+ if key == 'url':
+ return self.url
+ elif key == 'scroll':
+ return self.percentage
+ elif key == 'scroll_raw':
+ return self.percentage
+ elif key == 'history':
+ return self.backforward
+ elif key == 'tabs':
+ return self.tabindex
+ elif key == 'keypress':
+ return self.keystring
+ elif key == 'progress':
+ return self.prog
+ elif key == 'search_match':
+ return self.search_match
+ elif key.startswith('text:'):
+ new_text_widget = textbase.TextBase()
+ self._text_widgets.append(new_text_widget)
+ return new_text_widget
+ elif key.startswith('clock:') or key == 'clock':
+ return self.clock
+ else:
+ raise utils.Unreachable(key)
+
@pyqtSlot(str)
def _on_config_changed(self, option):
if option == 'statusbar.show':
@@ -225,47 +256,36 @@ class StatusBar(QWidget):
# Read the list and set widgets accordingly
for segment in config.val.statusbar.widgets:
- if segment == 'url':
- self._hbox.addWidget(self.url)
- self.url.show()
- elif segment == 'scroll':
- self._hbox.addWidget(self.percentage)
- self.percentage.show()
- elif segment == 'scroll_raw':
- self._hbox.addWidget(self.percentage)
- self.percentage.set_raw()
- self.percentage.show()
- elif segment == 'history':
- self._hbox.addWidget(self.backforward)
- self.backforward.enabled = True
- if tab:
- self.backforward.on_tab_changed(tab)
- elif segment == 'tabs':
- self._hbox.addWidget(self.tabindex)
- self.tabindex.show()
- elif segment == 'keypress':
- self._hbox.addWidget(self.keystring)
- self.keystring.show()
- elif segment == 'progress':
- self._hbox.addWidget(self.prog)
- self.prog.enabled = True
+ widget = self._get_widget_from_config(segment)
+ self._hbox.addWidget(widget)
+
+ if segment == 'scroll_raw':
+ widget.set_raw()
+ elif segment in ('history', 'progress'):
+ widget.enabled = True
if tab:
- self.prog.on_tab_changed(tab)
+ widget.on_tab_changed(tab)
+
+ # Do not call .show() for these widgets. They are not always shown, and
+ # dynamically show/hide themselves in their on_tab_changed() methods.
+ continue
elif segment.startswith('text:'):
- cur_widget = textbase.TextBase()
- self._text_widgets.append(cur_widget)
- cur_widget.setText(segment.split(':', maxsplit=1)[1])
- self._hbox.addWidget(cur_widget)
- cur_widget.show()
- else:
- raise utils.Unreachable(segment)
+ widget.setText(segment.split(':', maxsplit=1)[1])
+ elif segment.startswith('clock:') or segment == 'clock':
+ split_segment = segment.split(':', maxsplit=1)
+ if len(split_segment) == 2 and split_segment[1]:
+ widget.format = split_segment[1]
+ else:
+ widget.format = '%X'
+
+ widget.show()
def _clear_widgets(self):
"""Clear widgets before redrawing them."""
# Start with widgets hidden and show them when needed
for widget in [self.url, self.percentage,
self.backforward, self.tabindex,
- self.keystring, self.prog, *self._text_widgets]:
+ self.keystring, self.prog, self.clock, *self._text_widgets]:
assert isinstance(widget, QWidget)
widget.hide()
self._hbox.removeWidget(widget)
diff --git a/qutebrowser/mainwindow/statusbar/clock.py b/qutebrowser/mainwindow/statusbar/clock.py
new file mode 100644
index 000000000..20587a5ac
--- /dev/null
+++ b/qutebrowser/mainwindow/statusbar/clock.py
@@ -0,0 +1,54 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-2021 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 <https://www.gnu.org/licenses/>.
+
+"""Clock displayed in the statusbar."""
+from datetime import datetime
+
+from PyQt5.QtCore import Qt, QTimer
+
+from qutebrowser.mainwindow.statusbar import textbase
+
+
+class Clock(textbase.TextBase):
+
+ """Shows current time and date in the statusbar."""
+
+ UPDATE_DELAY = 500 # ms
+
+ def __init__(self, parent=None):
+ super().__init__(parent, elidemode=Qt.ElideNone)
+ self.format = ""
+
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self._show_time)
+
+ def _show_time(self):
+ """Set text to current time, using self.format as format-string."""
+ self.setText(datetime.now().strftime(self.format))
+
+ def hideEvent(self, event):
+ """Stop timer when widget is hidden."""
+ self.timer.stop()
+ super().hideEvent(event)
+
+ def showEvent(self, event):
+ """Override showEvent to show time and start self.timer for updating."""
+ self.timer.start(Clock.UPDATE_DELAY)
+ self._show_time()
+ super().showEvent(event)
diff --git a/qutebrowser/mainwindow/statusbar/searchmatch.py b/qutebrowser/mainwindow/statusbar/searchmatch.py
new file mode 100644
index 000000000..61baedf70
--- /dev/null
+++ b/qutebrowser/mainwindow/statusbar/searchmatch.py
@@ -0,0 +1,48 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2014-2021 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 <https://www.gnu.org/licenses/>.
+
+"""The search match indicator in the statusbar."""
+
+
+from PyQt5.QtCore import pyqtSlot
+
+from qutebrowser.browser import browsertab
+from qutebrowser.mainwindow.statusbar import textbase
+from qutebrowser.utils import log
+
+
+class SearchMatch(textbase.TextBase):
+
+ """The part of the statusbar that displays the search match counter."""
+
+ @pyqtSlot(browsertab.SearchMatch)
+ def set_match(self, search_match: browsertab.SearchMatch) -> None:
+ """Set the match counts in the statusbar.
+
+ Passing SearchMatch(0, 0) hides the match counter.
+
+ Args:
+ search_match: The currently active search match.
+ """
+ if search_match.is_null():
+ self.setText('')
+ log.statusbar.debug('Clearing search match text.')
+ else:
+ self.setText(f'Match [{search_match}]')
+ log.statusbar.debug(f'Setting search match text to {search_match}')
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index 68b4adfdb..c623ce809 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -185,6 +185,7 @@ class TabbedBrowser(QWidget):
arg 1: x-position in %.
arg 2: y-position in %.
cur_load_status_changed: Loading status of current tab changed.
+ cur_search_match_changed: The active search match changed.
close_window: The last tab was closed, close this window.
resized: Emitted when the browser window has resized, so the completion
widget can adjust its size to it.
@@ -201,6 +202,7 @@ class TabbedBrowser(QWidget):
cur_link_hovered = pyqtSignal(str)
cur_scroll_perc_changed = pyqtSignal(int, int)
cur_load_status_changed = pyqtSignal(usertypes.LoadStatus)
+ cur_search_match_changed = pyqtSignal(browsertab.SearchMatch)
cur_fullscreen_requested = pyqtSignal(bool)
cur_caret_selection_toggled = pyqtSignal(browsertab.SelectionState)
close_window = pyqtSignal()
@@ -347,6 +349,8 @@ class TabbedBrowser(QWidget):
self._filter.create(self.cur_fullscreen_requested, tab))
tab.caret.selection_toggled.connect(
self._filter.create(self.cur_caret_selection_toggled, tab))
+ tab.search.match_changed.connect(
+ self._filter.create(self.cur_search_match_changed, tab))
# misc
tab.scroller.perc_changed.connect(self._on_scroll_pos_changed)
tab.scroller.before_jump_requested.connect(lambda: self.set_mark("'"))
@@ -901,6 +905,7 @@ class TabbedBrowser(QWidget):
.format(current_mode.name, mode_on_change))
self._now_focused = tab
self.current_tab_changed.emit(tab)
+ self.cur_search_match_changed.emit(tab.search.match)
QTimer.singleShot(0, self._update_window_title)
self._tab_insert_idx_left = self.widget.currentIndex()
self._tab_insert_idx_right = self.widget.currentIndex() + 1
diff --git a/qutebrowser/misc/elf.py b/qutebrowser/misc/elf.py
index bf824880a..8fadbcffd 100644
--- a/qutebrowser/misc/elf.py
+++ b/qutebrowser/misc/elf.py
@@ -270,7 +270,7 @@ def _find_versions(data: bytes) -> Versions:
correctly: https://github.com/python/typeshed/issues/1467
"""
match = re.search(
- br'QtWebEngine/([0-9.]+) Chrome/([0-9.]+)',
+ br'\x00QtWebEngine/([0-9.]+) Chrome/([0-9.]+)\x00',
data,
)
if match is None:
diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py
index a28d662b3..77543f161 100644
--- a/qutebrowser/utils/utils.py
+++ b/qutebrowser/utils/utils.py
@@ -44,7 +44,7 @@ except ImportError: # pragma: no cover
"""Empty stub at runtime."""
-from PyQt5.QtCore import QUrl, QVersionNumber, QRect
+from PyQt5.QtCore import QUrl, QVersionNumber, QRect, QPoint
from PyQt5.QtGui import QClipboard, QDesktopServices
from PyQt5.QtWidgets import QApplication
@@ -839,3 +839,16 @@ def parse_rect(s: str) -> QRect:
raise ValueError("Invalid rectangle")
return rect
+
+
+def parse_point(s: str) -> QPoint:
+ """Parse a point string like 13,-42."""
+ try:
+ x, y = map(int, s.split(',', maxsplit=1))
+ except ValueError:
+ raise ValueError(f"String {s} does not match X,Y")
+
+ try:
+ return QPoint(x, y)
+ except OverflowError as e:
+ raise ValueError(e)
diff --git a/requirements.txt b/requirements.txt
index 228d637a1..129fe402a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@
adblock==0.5.2
colorama==0.4.4
-importlib-metadata==4.11.3 ; python_version=="3.7.*"
+importlib-metadata==4.11.4 ; python_version=="3.7.*"
importlib-resources==5.7.1 ; python_version=="3.7.*" or python_version=="3.8.*"
Jinja2==3.1.2
MarkupSafe==2.1.1
diff --git a/scripts/asciidoc2html.py b/scripts/asciidoc2html.py
index 6b4e3fb0d..ba8493247 100755
--- a/scripts/asciidoc2html.py
+++ b/scripts/asciidoc2html.py
@@ -43,7 +43,10 @@ class AsciiDoc:
"""Abstraction of an asciidoc subprocess."""
- FILES = ['faq', 'changelog', 'contributing', 'quickstart', 'userscripts']
+ FILES = [
+ 'faq', 'changelog', 'contributing', 'quickstart', 'userscripts',
+ 'install', 'stacktrace'
+ ]
def __init__(self,
asciidoc: Optional[str],
diff --git a/scripts/dev/changelog_urls.json b/scripts/dev/changelog_urls.json
index 0b6b071c5..6b33f15ef 100644
--- a/scripts/dev/changelog_urls.json
+++ b/scripts/dev/changelog_urls.json
@@ -1,6 +1,7 @@
{
"pyparsing": "https://github.com/pyparsing/pyparsing/blob/master/CHANGES",
- "pylint": "https://pylint.pycqa.org/en/latest/whatsnew/changelog.html",
+ "pylint": "https://pylint.pycqa.org/en/latest/whatsnew/2/index.html",
+ "tomlkit": "https://github.com/sdispater/tomlkit/blob/master/CHANGELOG.md",
"dill": "https://github.com/uqfoundation/dill/commits/master",
"isort": "https://pycqa.github.io/isort/CHANGELOG/",
"lazy-object-proxy": "https://github.com/ionelmc/python-lazy-object-proxy/blob/master/CHANGELOG.rst",
@@ -13,7 +14,7 @@
"execnet": "https://execnet.readthedocs.io/en/latest/changelog.html",
"pytest-rerunfailures": "https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst",
"pytest-repeat": "https://github.com/pytest-dev/pytest-repeat/blob/master/CHANGES.rst",
- "requests": "https://github.com/psf/requests/blob/master/HISTORY.md",
+ "requests": "https://github.com/psf/requests/blob/main/HISTORY.md",
"requests-file": "https://github.com/dashea/requests-file/blob/master/CHANGES.rst",
"Werkzeug": "https://werkzeug.palletsprojects.com/en/latest/changes/",
"click": "https://click.palletsprojects.com/en/latest/changes/",
@@ -25,6 +26,7 @@
"Mako": "https://docs.makotemplates.org/en/latest/changelog.html",
"glob2": "https://github.com/miracle2k/python-glob2/blob/master/CHANGES",
"hypothesis": "https://hypothesis.readthedocs.io/en/latest/changes.html",
+ "exceptiongroup": "https://github.com/agronholm/exceptiongroup/blob/main/CHANGES.rst",
"mypy": "https://mypy-lang.blogspot.com/",
"types-PyYAML": "https://github.com/python/typeshed/commits/master/stubs/PyYAML",
"pytest": "https://docs.pytest.org/en/latest/changelog.html",
@@ -51,7 +53,6 @@
"flake8-deprecated": "https://github.com/gforcada/flake8-deprecated/blob/master/CHANGES.rst",
"flake8-future-import": "https://github.com/xZise/flake8-future-import#changes",
"flake8-mock": "https://github.com/aleGpereira/flake8-mock#changes",
- "flake8-polyfill": "https://gitlab.com/pycqa/flake8-polyfill/-/blob/master/CHANGELOG.rst",
"flake8-string-format": "https://github.com/xZise/flake8-string-format#changes",
"flake8-plugin-utils": "https://github.com/afonasev/flake8-plugin-utils#change-log",
"flake8-pytest-style": "https://github.com/m-burst/flake8-pytest-style#change-log",
diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py
index 8f1d2df2b..0f8b23554 100644
--- a/scripts/dev/check_coverage.py
+++ b/scripts/dev/check_coverage.py
@@ -139,6 +139,8 @@ PERFECT_FILES = [
(None,
'qutebrowser/mainwindow/statusbar/keystring.py'),
+ (None,
+ 'qutebrowser/mainwindow/statusbar/searchmatch.py'),
('tests/unit/mainwindow/statusbar/test_percentage.py',
'qutebrowser/mainwindow/statusbar/percentage.py'),
('tests/unit/mainwindow/statusbar/test_progress.py',
diff --git a/scripts/dev/run_pylint_on_tests.py b/scripts/dev/run_pylint_on_tests.py
index 28c6e32c9..e044de976 100644
--- a/scripts/dev/run_pylint_on_tests.py
+++ b/scripts/dev/run_pylint_on_tests.py
@@ -64,6 +64,8 @@ def main():
'import-error',
# tests/helpers imports
'wrong-import-order',
+ # __tracebackhide__
+ 'unnecessary-lambda-assignment',
]
toxinidir = sys.argv[1]
diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py
index febd2bf8a..1267a278a 100755
--- a/scripts/dev/src2asciidoc.py
+++ b/scripts/dev/src2asciidoc.py
@@ -176,13 +176,15 @@ def _get_setting_quickref():
def _get_configtypes():
"""Get configtypes classes to document."""
- predicate = lambda e: (
- inspect.isclass(e) and
- # pylint: disable=protected-access
- e not in [configtypes.BaseType, configtypes.MappingType,
- configtypes._Numeric, configtypes.FontBase] and
- # pylint: enable=protected-access
- issubclass(e, configtypes.BaseType))
+ def predicate(e):
+ return (
+ inspect.isclass(e) and
+ # pylint: disable=protected-access
+ e not in [configtypes.BaseType, configtypes.MappingType,
+ configtypes._Numeric, configtypes.FontBase] and
+ # pylint: enable=protected-access
+ issubclass(e, configtypes.BaseType)
+ )
yield from inspect.getmembers(configtypes, predicate)
diff --git a/tests/end2end/data/click_element.html b/tests/end2end/data/click_element.html
index acf0cf77c..b2a691e08 100644
--- a/tests/end2end/data/click_element.html
+++ b/tests/end2end/data/click_element.html
@@ -6,9 +6,11 @@
<span id='test' onclick='console.log("click_element clicked")'>Test Element</span>
<span onclick='console.log("click_element special chars")'>"Don't", he shouted</span>
<span>Duplicate</span>
- <span>Duplicate</span>
- <form><input id='qute-input'></input></form>
+ <span class='clickable' onclick='console.log("click_element CSS selector")'>Duplicate</span>
+ <form><input autofocus id='qute-input'></input></form>
<a href="/data/hello.txt" id='link'>link</a>
<span id='foo.bar' onclick='console.log("id with dot")'>ID with dot</span>
+ <span style='position: absolute; left: 20px;top: 42px; width:10px; height:10px;'
+ onclick='console.log("click_element position")'></span>
</body>
</html>
diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py
index da42ac6e1..2bb3d5079 100644
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -712,3 +712,35 @@ def check_option_per_domain(quteproc, option, value, pattern, server):
pattern = pattern.replace('(port)', str(server.port))
actual_value = quteproc.get_setting(option, pattern=pattern)
assert actual_value == value
+
+
+@bdd.when(bdd.parsers.parse('I setup a fake {kind} fileselector '
+ 'selecting "{files}" and writes to {output_type}'))
+def set_up_fileselector(quteproc, py_proc, tmpdir, kind, files, output_type):
+ """Set up fileselect.xxx.command to select the file(s)."""
+ cmd, args = py_proc(r"""
+ import os
+ import sys
+ tmp_file = None
+ for i, arg in enumerate(sys.argv):
+ if arg.startswith('--file='):
+ tmp_file = arg[len('--file='):]
+ sys.argv.pop(i)
+ break
+ selected_files = sys.argv[1:]
+ if tmp_file is None:
+ for selected_file in selected_files:
+ print(os.path.abspath(selected_file))
+ else:
+ with open(tmp_file, 'w') as f:
+ for selected_file in selected_files:
+ f.write(os.path.abspath(selected_file) + '\n')
+ """)
+ files = files.replace('(tmpdir)', str(tmpdir))
+ files = files.replace('(dirsep)', os.sep)
+ args += files.split(' ')
+ if output_type == "a temporary file":
+ args += ['--file={}']
+ fileselect_cmd = json.dumps([cmd, *args])
+ quteproc.set_setting('fileselect.handler', 'external')
+ quteproc.set_setting(f'fileselect.{kind}.command', fileselect_cmd)
diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature
index dfdb24704..a1af893b6 100644
--- a/tests/end2end/features/downloads.feature
+++ b/tests/end2end/features/downloads.feature
@@ -684,3 +684,32 @@ Feature: Downloading things from a website.
When I set downloads.location.prompt to false
And I open 500-inline
Then the error "Download error: *INTERNAL SERVER ERROR" should be shown
+
+ ## External download path fileselector
+
+ Scenario: Select download path
+ When I set downloads.location.prompt to true
+ And I setup a fake folder fileselector selecting "(tmpdir)(dirsep)downloads(dirsep)subdir" and writes to a temporary file
+ And I open data/downloads/downloads.html
+ And I run :click-element id download
+ And I wait for the download prompt for "*"
+ And I run :prompt-fileselect-external
+ And I wait until the download is finished
+ Then the downloaded file subdir/download.bin should exist
+
+ Scenario: No download folder chosen
+ When I set downloads.location.prompt to true
+ And I set fileselect.folder.command to ['echo', '{}']
+ And I open data/downloads/downloads.html
+ And I run :click-element id download
+ And I wait for the download prompt for "*"
+ And I run :prompt-fileselect-external
+ Then the message "No folder chosen." should be shown
+ And "No prompts left, hiding prompt container." should not be logged
+
+ Scenario: Using :prompt-fileselect-external with other prompt
+ When I open data/prompt/jsprompt.html
+ And I run :click-element id button
+ And I wait for "Asking question *" in the log
+ And I run :prompt-fileselect-external
+ Then the error "Can only launch external fileselect for FilenamePrompt, not LineEditPrompt" should be shown
diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature
index bd8ada576..26fe8f357 100644
--- a/tests/end2end/features/misc.feature
+++ b/tests/end2end/features/misc.feature
@@ -436,7 +436,7 @@ Feature: Various utility commands.
Scenario: Clicking an element with unknown ID
When I open data/click_element.html
And I run :click-element id blah
- Then the error "No element found with id blah!" should be shown
+ Then the error "No element found with ID "blah"!" should be shown
Scenario: Clicking an element by ID
When I open data/click_element.html
@@ -457,6 +457,49 @@ Feature: Various utility commands.
- data/click_element.html
- data/hello.txt (active)
+ Scenario: Clicking an element by CSS selector
+ When I open data/click_element.html
+ And I run :click-element css .clickable
+ Then the javascript message "click_element CSS selector" should be logged
+
+ Scenario: Clicking an element with non-unique filter
+ When I open data/click_element.html
+ And I run :click-element css span
+ Then the error "Multiple elements found matching CSS selector "span"!" should be shown
+
+ Scenario: Clicking first element matching a selector
+ When I open data/click_element.html
+ And I run :click-element --select-first css span
+ Then the javascript message "click_element clicked" should be logged
+
+ Scenario: Clicking an element by position
+ When I open data/click_element.html
+ And I run :click-element position 20,42
+ Then the javascript message "click_element position" should be logged
+
+ Scenario: Clicking an element with invalid position
+ When I open data/click_element.html
+ And I run :click-element position 20.42
+ Then the error "String 20.42 does not match X,Y" should be shown
+
+ Scenario: Clicking an element with non-integer position
+ When I open data/click_element.html
+ And I run :click-element position 20,42.001
+ Then the error "String 20,42.001 does not match X,Y" should be shown
+
+ Scenario: Clicking on focused element when there is none
+ When I open data/click_element.html
+ # Need to loose focus on input element
+ And I run :click-element position 20,42
+ And I wait for the javascript message "click_element position"
+ And I run :click-element focused
+ Then the error "No element found with focus!" should be shown
+
+ Scenario: Clicking on focused element
+ When I open data/click_element.html
+ And I run :click-element focused
+ Then "Entering mode KeyMode.insert (reason: clicking input)" should be logged
+
## :command-history-{prev,next}
Scenario: Calling previous command
diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature
index 305b45690..9446c36ac 100644
--- a/tests/end2end/features/search.feature
+++ b/tests/end2end/features/search.feature
@@ -202,6 +202,22 @@ Feature: Searching on a page
And I wait for "prev_result found foo" in the log
Then "Foo" should be found
+ # This makes sure we don't mutate the original flags
+ # Seems to be broken with QtWebKit, wontfix
+ @qtwebkit_skip
+ Scenario: Jumping to previous match with --reverse twice
+ When I set search.ignore_case to always
+ And I run :search --reverse baz
+ # BAZ
+ And I wait for "search found baz with flags FindBackward" in the log
+ And I run :search-prev
+ # Baz
+ And I wait for "prev_result found baz" in the log
+ And I run :search-prev
+ # baz
+ And I wait for "prev_result found baz" in the log
+ Then "baz" should be found
+
Scenario: Jumping to previous match without search
# Make sure there was no search in the same window before
When I open data/search.html in a new window
@@ -233,20 +249,20 @@ Feature: Searching on a page
## wrapping prevented
- @qtwebkit_skip @qt>=5.14
- Scenario: Preventing wrapping at the top of the page with QtWebEngine
+ @qt>=5.14
+ Scenario: Preventing wrapping at the top of the page
When I set search.ignore_case to always
And I set search.wrap to false
+ And I set search.wrap_messages to true
And I run :search --reverse foo
And I wait for "search found foo with flags FindBackward" in the log
And I run :search-next
And I wait for "next_result found foo with flags FindBackward" in the log
And I run :search-next
- And I wait for "Search hit TOP" in the log
- Then "foo" should be found
+ Then the message "Search hit TOP" should be shown
- @qtwebkit_skip @qt>=5.14
- Scenario: Preventing wrapping at the bottom of the page with QtWebEngine
+ @qt>=5.14
+ Scenario: Preventing wrapping at the bottom of the page
When I set search.ignore_case to always
And I set search.wrap to false
And I run :search foo
@@ -254,32 +270,49 @@ Feature: Searching on a page
And I run :search-next
And I wait for "next_result found foo" in the log
And I run :search-next
- And I wait for "Search hit BOTTOM" in the log
- Then "Foo" should be found
+ Then the message "Search hit BOTTOM" should be shown
- @qtwebengine_skip
- Scenario: Preventing wrapping at the top of the page with QtWebKit
+ ## search match counter
+
+ @qtwebkit_skip @qt>=5.14
+ Scenario: Setting search match counter on search
When I set search.ignore_case to always
- And I set search.wrap to false
- And I run :search --reverse foo
- And I wait for "search found foo with flags FindBackward" in the log
+ And I set search.wrap to true
+ And I run :search ba
+ And I wait for "search found ba" in the log
+ Then "Setting search match text to 1/5" should be logged
+
+ @qtwebkit_skip @qt>=5.14
+ Scenario: Updating search match counter on search-next
+ When I set search.ignore_case to always
+ And I set search.wrap to true
+ And I run :search ba
+ And I wait for "search found ba" in the log
And I run :search-next
- And I wait for "next_result found foo with flags FindBackward" in the log
+ And I wait for "next_result found ba" in the log
And I run :search-next
- And I wait for "next_result didn't find foo with flags FindBackward" in the log
- Then the warning "Text 'foo' not found on page!" should be shown
+ And I wait for "next_result found ba" in the log
+ Then "Setting search match text to 3/5" should be logged
- @qtwebengine_skip
- Scenario: Preventing wrapping at the bottom of the page with QtWebKit
+ @qtwebkit_skip @qt>=5.14
+ Scenario: Updating search match counter on search-prev with wrapping
+ When I set search.ignore_case to always
+ And I set search.wrap to true
+ And I run :search ba
+ And I wait for "search found ba" in the log
+ And I run :search-prev
+ And I wait for the message "Search hit TOP, continuing at BOTTOM"
+ Then "Setting search match text to 5/5" should be logged
+
+ @qtwebkit_skip @qt>=5.14
+ Scenario: Updating search match counter on search-prev without wrapping
When I set search.ignore_case to always
And I set search.wrap to false
- And I run :search foo
- And I wait for "search found foo" in the log
- And I run :search-next
- And I wait for "next_result found foo" in the log
- And I run :search-next
- And I wait for "next_result didn't find foo" in the log
- Then the warning "Text 'foo' not found on page!" should be shown
+ And I run :search ba
+ And I wait for "search found ba" in the log
+ And I run :search-prev
+ And I wait for the message "Search hit TOP"
+ Then "Setting search match text to 1/5" should be logged
## follow searched links
@skip # Too flaky
diff --git a/tests/end2end/features/test_editor_bdd.py b/tests/end2end/features/test_editor_bdd.py
index 40f77a0f7..6a4da49de 100644
--- a/tests/end2end/features/test_editor_bdd.py
+++ b/tests/end2end/features/test_editor_bdd.py
@@ -178,33 +178,3 @@ def save_editor_wait(tmpdir):
# for posix, there IS a member so we need to ignore useless-suppression
# pylint: disable=no-member,useless-suppression
os.kill(pid, signal.SIGUSR2)
-
-
-@bdd.when(bdd.parsers.parse('I setup a fake {kind} fileselector '
- 'selecting "{files}" and writes to {output_type}'))
-def set_up_fileselector(quteproc, py_proc, kind, files, output_type):
- """Set up fileselect.xxx.command to select the file(s)."""
- cmd, args = py_proc(r"""
- import os
- import sys
- tmp_file = None
- for i, arg in enumerate(sys.argv):
- if arg.startswith('--file='):
- tmp_file = arg[len('--file='):]
- sys.argv.pop(i)
- break
- selected_files = sys.argv[1:]
- if tmp_file is None:
- for selected_file in selected_files:
- print(os.path.abspath(selected_file))
- else:
- with open(tmp_file, 'w') as f:
- for selected_file in selected_files:
- f.write(os.path.abspath(selected_file) + '\n')
- """)
- args += files.split(' ')
- if output_type == "a temporary file":
- args += ['--file={}']
- fileselect_cmd = json.dumps([cmd, *args])
- quteproc.set_setting('fileselect.handler', 'external')
- quteproc.set_setting(f'fileselect.{kind}.command', fileselect_cmd)
diff --git a/tests/unit/browser/test_caret.py b/tests/unit/browser/test_caret.py
index 86014040d..85301e358 100644
--- a/tests/unit/browser/test_caret.py
+++ b/tests/unit/browser/test_caret.py
@@ -345,8 +345,8 @@ class TestSearch:
callback.assert_called_with(True)
with qtbot.wait_callback() as callback:
- web_tab.search.next_result(result_cb=callback)
- callback.assert_called_with(True)
+ web_tab.search.next_result(callback=callback)
+ callback.assert_called_with(browsertab.SearchNavigationResult.found)
mode_manager.enter(usertypes.KeyMode.caret)
diff --git a/tests/unit/browser/webengine/test_webenginetab.py b/tests/unit/browser/webengine/test_webenginetab.py
index 3d8eec663..30807bb4e 100644
--- a/tests/unit/browser/webengine/test_webenginetab.py
+++ b/tests/unit/browser/webengine/test_webenginetab.py
@@ -214,3 +214,46 @@ def test_notification_permission_workaround():
permissions = webenginetab._WebEnginePermissions
assert permissions._options[notifications] == 'content.notifications.enabled'
assert permissions._messages[notifications] == 'show notifications'
+
+
+class TestFindFlags:
+
+ @pytest.mark.parametrize("case_sensitive, backward, expected", [
+ (True, True, (QWebEnginePage.FindFlag.FindCaseSensitively |
+ QWebEnginePage.FindFlag.FindBackward)),
+ (True, False, QWebEnginePage.FindFlag.FindCaseSensitively),
+ (False, True, QWebEnginePage.FindFlag.FindBackward),
+ (False, False, QWebEnginePage.FindFlag(0)),
+ ])
+ def test_to_qt(self, case_sensitive, backward, expected):
+ flags = webenginetab._FindFlags(
+ case_sensitive=case_sensitive,
+ backward=backward,
+ )
+ assert flags.to_qt() == expected
+
+ @pytest.mark.parametrize("case_sensitive, backward, expected", [
+ (True, True, True),
+ (True, False, True),
+ (False, True, True),
+ (False, False, False),
+ ])
+ def test_bool(self, case_sensitive, backward, expected):
+ flags = webenginetab._FindFlags(
+ case_sensitive=case_sensitive,
+ backward=backward,
+ )
+ assert bool(flags) == expected
+
+ @pytest.mark.parametrize("case_sensitive, backward, expected", [
+ (True, True, "FindCaseSensitively|FindBackward"),
+ (True, False, "FindCaseSensitively"),
+ (False, True, "FindBackward"),
+ (False, False, "<no find flags>"),
+ ])
+ def test_str(self, case_sensitive, backward, expected):
+ flags = webenginetab._FindFlags(
+ case_sensitive=case_sensitive,
+ backward=backward,
+ )
+ assert str(flags) == expected
diff --git a/tests/unit/browser/webkit/http/test_http.py b/tests/unit/browser/webkit/http/test_http.py
index 4db78f4ff..d50f1c277 100644
--- a/tests/unit/browser/webkit/http/test_http.py
+++ b/tests/unit/browser/webkit/http/test_http.py
@@ -44,6 +44,21 @@ def test_no_content_disposition(stubs, url, expected):
assert filename == expected
+@pytest.mark.parametrize('value', [
+ # https://github.com/python/cpython/issues/87112
+ 'inline; 0*²'.encode("iso-8859-1"),
+ # https://github.com/python/cpython/issues/81672
+ b'"',
+ # https://github.com/python/cpython/issues/93010
+ b'attachment; 0*00="foo"',
+ # FIXME: Should probably have more tests if this is still relevant after
+ # dropping QtWebKit.
+])
+def test_parse_content_disposition_invalid(value):
+ with pytest.raises(http.ContentDispositionError):
+ http.ContentDisposition.parse(value)
+
+
@pytest.mark.parametrize('template', [
'{}',
'attachment; filename="{}"',
diff --git a/tests/unit/browser/webkit/test_webkitelem.py b/tests/unit/browser/webkit/test_webkitelem.py
index f7cc3e8c2..7df91922d 100644
--- a/tests/unit/browser/webkit/test_webkitelem.py
+++ b/tests/unit/browser/webkit/test_webkitelem.py
@@ -195,6 +195,7 @@ class SelectionAndFilterTests:
('<p role="menuitem" foo="bar"/>', ['all']),
('<p role="menuitemcheckbox" foo="bar"/>', ['all']),
('<p role="menuitemradio" foo="bar"/>', ['all']),
+ ('<p role="treeitem" foo="bar"/>', ['all']),
('<p role="button" foo="bar"/>', ['all']),
('<p role="button" href="bar"/>', ['all', 'url']),
diff --git a/tests/unit/misc/test_elf.py b/tests/unit/misc/test_elf.py
index 86060bbde..7d3248da2 100644
--- a/tests/unit/misc/test_elf.py
+++ b/tests/unit/misc/test_elf.py
@@ -75,6 +75,23 @@ def test_result(qapp, caplog):
assert ua.upstream_browser_version == versions.chromium
+@pytest.mark.parametrize("data, expected", [
+ # Simple match
+ (
+ b"\x00QtWebEngine/5.15.9 Chrome/87.0.4280.144\x00",
+ elf.Versions("5.15.9", "87.0.4280.144"),
+ ),
+ # Ignoring garbage string-like data
+ (
+ b"\x00QtWebEngine/5.15.9 Chrome/87.0.4xternalclearkey\x00\x00"
+ b"QtWebEngine/5.15.9 Chrome/87.0.4280.144\x00",
+ elf.Versions("5.15.9", "87.0.4280.144"),
+ ),
+])
+def test_find_versions(data, expected):
+ assert elf._find_versions(data) == expected
+
+
@hypothesis.given(data=hst.builds(
lambda *a: b''.join(a),
hst.sampled_from([b'', b'\x7fELF', b'\x7fELF\x02\x01\x01']),
diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py
index bbc6b02db..20938d6fb 100644
--- a/tests/unit/utils/test_log.py
+++ b/tests/unit/utils/test_log.py
@@ -236,6 +236,7 @@ class TestInitLog:
"""Tests for init_log."""
def _get_default_args(self):
+ # pylint: disable-next=unused-variable
return argparse.Namespace(debug=True, loglevel='debug', color=True,
loglines=10, logfilter=None,
force_color=False, json_logging=False,
diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py
index 4620c2198..c833aa677 100644
--- a/tests/unit/utils/test_utils.py
+++ b/tests/unit/utils/test_utils.py
@@ -30,7 +30,7 @@ import shlex
import math
import operator
-from PyQt5.QtCore import QUrl, QRect
+from PyQt5.QtCore import QUrl, QRect, QPoint
from PyQt5.QtGui import QClipboard
import pytest
import hypothesis
@@ -603,6 +603,15 @@ class TestSanitizeFilename:
LONG_EXTENSION = (LONG_FILENAME.replace("filename", ".extension")
.replace(".txt", ""))
+ # first four-byte unicode char
+ U10K = "\U00010000"
+
+ LONG_4BYTE = U10K * 64
+ LONG_4BYTE_SHORTENED = U10K * 60
+
+ LONG_4BYTE_EXT = f"{U10K * 8}.{U10K * 64}"
+ LONG_4BYTE_EXT_SHORTENED = f"{U10K}.{U10K * 59}"
+
@pytest.mark.parametrize('inp, expected', [
pytest.param('normal.txt', 'normal.txt',
marks=pytest.mark.fake_os('windows')),
@@ -629,6 +638,14 @@ class TestSanitizeFilename:
LONG_EXTENSION.replace("this is a very long .extension",
"this .extension"),
),
+ (
+ LONG_4BYTE,
+ LONG_4BYTE_SHORTENED,
+ ),
+ (
+ LONG_4BYTE_EXT,
+ LONG_4BYTE_EXT_SHORTENED,
+ )
])
@pytest.mark.linux
def test_shorten(self, inp, expected):
@@ -1026,3 +1043,44 @@ class TestParseRect:
utils.parse_rect(s)
except ValueError as e:
print(e)
+
+
+class TestParsePoint:
+
+ @pytest.mark.parametrize('value, expected', [
+ ('1,1', QPoint(1, 1)),
+ ('123,789', QPoint(123, 789)),
+ ('-123,-789', QPoint(-123, -789)),
+ ])
+ def test_valid(self, value, expected):
+ assert utils.parse_point(value) == expected
+
+ @pytest.mark.parametrize('value, message', [
+ ('1x1', "String 1x1 does not match X,Y"),
+ ('1e0,1', "String 1e0,1 does not match X,Y"),
+ ('a,1', "String a,1 does not match X,Y"),
+ ('¹,1', "String ¹,1 does not match X,Y"),
+ ('1,,1', "String 1,,1 does not match X,Y"),
+ ('1', "String 1 does not match X,Y"),
+ ])
+ def test_invalid(self, value, message):
+ with pytest.raises(ValueError) as excinfo:
+ utils.parse_point(value)
+ assert str(excinfo.value) == message
+
+ @hypothesis.given(strategies.text())
+ def test_hypothesis_text(self, s):
+ try:
+ utils.parse_point(s)
+ except ValueError as e:
+ print(e)
+
+ @hypothesis.given(strategies.tuples(
+ strategies.integers(),
+ strategies.integers(),
+ ).map(lambda t: ",".join(map(str, t))))
+ def test_hypothesis_sophisticated(self, s):
+ try:
+ utils.parse_point(s)
+ except ValueError as e:
+ print(e)
diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py
index 7b616d8b7..64df0ece2 100644
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -1463,7 +1463,11 @@ def test_uptime(monkeypatch, qapp):
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
+
+ @classmethod
+ def now(cls, tz=None):
+ return datetime.datetime(1, 1, 1, 1, 1, 1, 2)
+
monkeypatch.setattr(datetime, 'datetime', FakeDateTime)
uptime_delta = version._uptime()