summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.pylintrc3
-rw-r--r--doc/changelog.asciidoc36
-rw-r--r--doc/help/commands.asciidoc23
-rw-r--r--doc/help/settings.asciidoc13
-rwxr-xr-xmisc/nsis/install.nsh12
-rwxr-xr-xmisc/nsis/qutebrowser.nsi3
-rw-r--r--misc/requirements/requirements-dev.txt8
-rw-r--r--misc/requirements/requirements-flake8.txt3
-rw-r--r--misc/requirements/requirements-mypy.txt6
-rw-r--r--misc/requirements/requirements-pyinstaller.txt2
-rw-r--r--misc/requirements/requirements-pylint.txt7
-rw-r--r--misc/requirements/requirements-pyroma.txt2
-rw-r--r--misc/requirements/requirements-sphinx.txt4
-rw-r--r--misc/requirements/requirements-tests.txt9
-rw-r--r--misc/requirements/requirements-tox.txt6
-rwxr-xr-xmisc/userscripts/qute-keepassxc94
-rwxr-xr-xmisc/userscripts/qutedmenu6
-rw-r--r--qutebrowser/browser/browsertab.py84
-rw-r--r--qutebrowser/browser/commands.py112
-rw-r--r--qutebrowser/browser/shared.py4
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py242
-rw-r--r--qutebrowser/browser/webkit/webkittab.py49
-rw-r--r--qutebrowser/completion/completer.py1
-rw-r--r--qutebrowser/completion/models/configmodel.py18
-rw-r--r--qutebrowser/components/misccommands.py88
-rw-r--r--qutebrowser/config/configdata.yml15
-rw-r--r--qutebrowser/config/configtypes.py2
-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/sessions.py28
-rw-r--r--qutebrowser/utils/utils.py15
-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.py41
-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/sessions.feature20
-rw-r--r--tests/end2end/features/test_editor_bdd.py30
-rw-r--r--tests/end2end/fixtures/quteprocess.py10
-rw-r--r--tests/unit/browser/test_caret.py4
-rw-r--r--tests/unit/browser/webengine/test_webenginetab.py43
-rw-r--r--tests/unit/utils/test_log.py1
-rw-r--r--tests/unit/utils/test_utils.py43
-rw-r--r--tests/unit/utils/test_version.py6
53 files changed, 1116 insertions, 396 deletions
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 139800670..da19f347b 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
~~~~~~~
@@ -55,12 +63,40 @@ Changed
`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.
+- The `:session-save` command now has a new `--no-history` flag, to exclude tab
+ history.
+- 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 (2022-05-26)
diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc
index 6003c0f1f..aaafa9188 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
@@ -1255,7 +1262,7 @@ Load a session.
[[session-save]]
=== session-save
-Syntax: +:session-save [*--current*] [*--quiet*] [*--force*] [*--only-active-window*] [*--with-private*] ['name']+
+Syntax: +:session-save [*--current*] [*--quiet*] [*--force*] [*--only-active-window*] [*--with-private*] [*--no-history*] ['name']+
Save a session.
@@ -1269,6 +1276,7 @@ Save a session.
* +*-f*+, +*--force*+: Force saving internal sessions (starting with an underline).
* +*-o*+, +*--only-active-window*+: Saves only tabs of the currently active window.
* +*-p*+, +*--with-private*+: Include private windows.
+* +*-n*+, +*--no-history*+: Don't store tab history.
[[set]]
=== set
@@ -1671,6 +1679,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 +1868,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 1236dc3ac..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]+
@@ -4001,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.
@@ -4127,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/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/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt
index 03f102e14..e4e768353 100644
--- a/misc/requirements/requirements-dev.txt
+++ b/misc/requirements/requirements-dev.txt
@@ -14,11 +14,11 @@ hunter==3.4.3
idna==3.3
importlib-metadata==4.11.4
jeepney==0.8.0
-keyring==23.5.1
+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
@@ -28,7 +28,7 @@ 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.4
@@ -37,7 +37,7 @@ 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 db9dad8e2..217089191 100644
--- a/misc/requirements/requirements-flake8.txt
+++ b/misc/requirements/requirements-flake8.txt
@@ -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.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 2119bd293..a4b555cf3 100644
--- a/misc/requirements/requirements-mypy.txt
+++ b/misc/requirements/requirements-mypy.txt
@@ -5,14 +5,14 @@ diff-cover==6.5.0
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.960
+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 9f7b95f73..35e65b6da 100644
--- a/misc/requirements/requirements-pyinstaller.txt
+++ b/misc/requirements/requirements-pyinstaller.txt
@@ -2,4 +2,4 @@
altgraph==0.17.2
pyinstaller==5.1
-pyinstaller-hooks-contrib==2022.6
+pyinstaller-hooks-contrib==2022.7
diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt
index e891a2032..38231fa12 100644
--- a/misc/requirements/requirements-pylint.txt
+++ b/misc/requirements/requirements-pylint.txt
@@ -12,16 +12,17 @@ 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.4.0
-pylint==2.13.9
+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
+tomlkit==0.11.0
typed-ast==1.5.4 ; python_version<"3.8"
typing_extensions==4.2.0
uritemplate==4.1.1
diff --git a/misc/requirements/requirements-pyroma.txt b/misc/requirements/requirements-pyroma.txt
index 28ec97c25..382418dd9 100644
--- a/misc/requirements/requirements-pyroma.txt
+++ b/misc/requirements/requirements-pyroma.txt
@@ -10,6 +10,6 @@ pep517==0.12.0
Pygments==2.12.0
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 f106bb482..f100b6dc0 100644
--- a/misc/requirements/requirements-sphinx.txt
+++ b/misc/requirements/requirements-sphinx.txt
@@ -14,9 +14,9 @@ packaging==21.3
Pygments==2.12.0
pyparsing==3.0.9
pytz==2022.1
-requests==2.27.1
+requests==2.28.0
snowballstemmer==2.2.0
-Sphinx==5.0.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.txt b/misc/requirements/requirements-tests.txt
index 18031cdac..3e9f3233d 100644
--- a/misc/requirements/requirements-tests.txt
+++ b/misc/requirements/requirements-tests.txt
@@ -6,13 +6,14 @@ certifi==2022.5.18.1
charset-normalizer==2.0.12
cheroot==8.6.0
click==8.1.3
-coverage==6.4
+coverage==6.4.1
+exceptiongroup==1.0.0rc8
execnet==1.9.0
-filelock==3.7.0
+filelock==3.7.1
Flask==2.1.2
glob2==0.7
hunter==3.4.3
-hypothesis==6.46.9
+hypothesis==6.47.2
idna==3.3
importlib-metadata==4.11.4
iniconfig==1.1.1
@@ -44,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
diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt
index ab576ae98..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.7.0
+filelock==3.7.1
packaging==21.3
-pip==22.1.1
+pip==22.1.2
platformdirs==2.5.2
pluggy==1.0.0
py==1.11.0
pyparsing==3.0.9
-setuptools==62.3.2
+setuptools==62.3.4
six==1.16.0
toml==0.10.2
tox==3.25.0
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/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py
index 699fe1b0b..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
@@ -667,6 +730,9 @@ class AbstractHistory:
def current_idx(self) -> int:
raise NotImplementedError
+ def current_item(self) -> Union['QWebHistoryItem', 'QWebEngineHistoryItem']:
+ raise NotImplementedError
+
def back(self, count: int = 1) -> None:
"""Go back in the tab's history."""
self._check_count(count)
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/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/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index 15729ccdc..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 prev_result(self, *, wrap=False, callback=None):
+ going_up = not self._flags.backward
+ flags = dataclasses.replace(self._flags, backward=going_up)
- def next_result(self, *, result_cb=None):
- going_up = self._flags & QWebEnginePage.FindBackward
- if self._wrap_handler.prevent_wrapping(going_up=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):
@@ -696,6 +691,9 @@ class WebEngineHistory(browsertab.AbstractHistory):
def current_idx(self):
return self._history.currentItemIndex()
+ def current_item(self):
+ return self._history.currentItem()
+
def can_go_back(self):
return self._history.canGoBack()
diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py
index 24d232c9c..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]
+
+ 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, *, result_cb=None):
+ 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):
@@ -686,6 +708,9 @@ class WebKitHistory(browsertab.AbstractHistory):
def current_idx(self):
return self._history.currentItemIndex()
+ def current_item(self):
+ return self._history.currentItem()
+
def can_go_back(self):
return self._history.canGoBack()
diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py
index 34e68fd2a..cf6984288 100644
--- a/qutebrowser/completion/completer.py
+++ b/qutebrowser/completion/completer.py
@@ -165,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 e91d9aaf1..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
@@ -2058,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
@@ -3789,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 97011b7cf..eef43ded4 100644
--- a/qutebrowser/config/configtypes.py
+++ b/qutebrowser/config/configtypes.py
@@ -2015,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/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/sessions.py b/qutebrowser/misc/sessions.py
index a28f3a848..0e0d79510 100644
--- a/qutebrowser/misc/sessions.py
+++ b/qutebrowser/misc/sessions.py
@@ -242,17 +242,21 @@ class SessionManager(QObject):
return data
- def _save_tab(self, tab, active):
+ def _save_tab(self, tab, active, with_history=True):
"""Get a dict with data for a single tab.
Args:
tab: The WebView to save.
active: Whether the tab is currently active.
+ with_history: Include the tab's history.
"""
data: _JsonType = {'history': []}
if active:
data['active'] = True
- for idx, item in enumerate(tab.history):
+
+ history = tab.history if with_history else [tab.history.current_item()]
+
+ for idx, item in enumerate(history):
qtutils.ensure_valid(item)
item_data = self._save_tab_item(tab, idx, item)
if item.url().scheme() == 'qute' and item.url().host() == 'back':
@@ -264,7 +268,7 @@ class SessionManager(QObject):
data['history'].append(item_data)
return data
- def _save_all(self, *, only_window=None, with_private=False):
+ def _save_all(self, *, only_window=None, with_private=False, with_history=True):
"""Get a dict with data for all windows/tabs."""
data: _JsonType = {'windows': []}
if only_window is not None:
@@ -295,7 +299,8 @@ class SessionManager(QObject):
win_data['private'] = True
for i, tab in enumerate(tabbed_browser.widgets()):
active = i == tabbed_browser.widget.currentIndex()
- win_data['tabs'].append(self._save_tab(tab, active))
+ win_data['tabs'].append(self._save_tab(tab, active,
+ with_history=with_history))
data['windows'].append(win_data)
return data
@@ -316,7 +321,7 @@ class SessionManager(QObject):
return name
def save(self, name, last_window=False, load_next_time=False,
- only_window=None, with_private=False):
+ only_window=None, with_private=False, with_history=True):
"""Save a named session.
Args:
@@ -327,6 +332,7 @@ class SessionManager(QObject):
load_next_time: If set, prepares this session to be load next time.
only_window: If set, only tabs in the specified window is saved.
with_private: Include private windows.
+ with_history: Include tab history.
Return:
The name of the saved session.
@@ -342,7 +348,8 @@ class SessionManager(QObject):
return None
else:
data = self._save_all(only_window=only_window,
- with_private=with_private)
+ with_private=with_private,
+ with_history=with_history)
log.sessions.vdebug( # type: ignore[attr-defined]
"Saving data: {}".format(data))
try:
@@ -576,12 +583,14 @@ def session_load(name: str, *,
@cmdutils.argument('name', completion=miscmodels.session)
@cmdutils.argument('win_id', value=cmdutils.Value.win_id)
@cmdutils.argument('with_private', flag='p')
+@cmdutils.argument('no_history', flag='n')
def session_save(name: ArgType = default, *,
current: bool = False,
quiet: bool = False,
force: bool = False,
only_active_window: bool = False,
with_private: bool = False,
+ no_history: bool = False,
win_id: int = None) -> None:
"""Save a session.
@@ -593,6 +602,7 @@ def session_save(name: ArgType = default, *,
force: Force saving internal sessions (starting with an underline).
only_active_window: Saves only tabs of the currently active window.
with_private: Include private windows.
+ no_history: Don't store tab history.
"""
if not isinstance(name, Sentinel) and name.startswith('_') and not force:
raise cmdutils.CommandError("{} is an internal session, use --force "
@@ -605,9 +615,11 @@ def session_save(name: ArgType = default, *,
try:
if only_active_window:
name = session_manager.save(name, only_window=win_id,
- with_private=True)
+ with_private=True,
+ with_history=not no_history)
else:
- name = session_manager.save(name, with_private=with_private)
+ name = session_manager.save(name, with_private=with_private,
+ with_history=not no_history)
except SessionError as e:
raise cmdutils.CommandError("Error while saving session: {}".format(e))
else:
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/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..4504b4f20 100644
--- a/tests/end2end/features/conftest.py
+++ b/tests/end2end/features/conftest.py
@@ -533,7 +533,7 @@ def javascript_message_not_logged(quteproc, message):
@bdd.then(bdd.parsers.parse("The session should look like:\n{expected}"))
-def compare_session(request, quteproc, expected):
+def compare_session(quteproc, expected):
"""Compare the current sessions against the given template.
partial_compare is used, which means only the keys/values listed will be
@@ -542,6 +542,13 @@ def compare_session(request, quteproc, expected):
quteproc.compare_session(expected)
+@bdd.then(
+ bdd.parsers.parse("The session saved with {flags} should look like:\n{expected}"))
+def compare_session_flags(quteproc, flags, expected):
+ """Compare the current session saved with custom flags."""
+ quteproc.compare_session(expected, flags=flags)
+
+
@bdd.then("no crash should happen")
def no_crash():
"""Don't do anything.
@@ -712,3 +719,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/sessions.feature b/tests/end2end/features/sessions.feature
index 37254be73..c68cebbc2 100644
--- a/tests/end2end/features/sessions.feature
+++ b/tests/end2end/features/sessions.feature
@@ -196,6 +196,26 @@ Feature: Saving and loading sessions
url: http://localhost:*/data/numbers/3.txt
zoom: 1.0
+ Scenario: Saving with --no-history
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt
+ And I open data/numbers/3.txt
+ Then the session saved with --no-history should look like:
+ windows:
+ - tabs:
+ - history:
+ - url: http://localhost:*/data/numbers/3.txt
+
+ Scenario: Saving with --no-history and --only-active-window
+ When I open data/numbers/1.txt
+ And I open data/numbers/2.txt
+ And I open data/numbers/3.txt
+ Then the session saved with --no-history --only-active-window should look like:
+ windows:
+ - tabs:
+ - history:
+ - url: http://localhost:*/data/numbers/3.txt
+
# https://github.com/qutebrowser/qutebrowser/issues/879
Scenario: Saving a session with a page using history.replaceState()
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/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py
index ab8f28d26..6e47814fd 100644
--- a/tests/end2end/fixtures/quteprocess.py
+++ b/tests/end2end/fixtures/quteprocess.py
@@ -873,13 +873,13 @@ class QuteProc(testprocess.Process):
self.wait_for_load_finished_url(url, timeout=timeout,
load_status=load_status)
- def get_session(self):
+ def get_session(self, flags="--with-private"):
"""Save the session and get the parsed session data."""
with tempfile.TemporaryDirectory() as tdir:
session = pathlib.Path(tdir) / 'session.yml'
- self.send_cmd(':session-save --with-private "{}"'.format(session))
+ self.send_cmd(f':session-save {flags} "{session}"')
self.wait_for(category='message', loglevel=logging.INFO,
- message='Saved session {}.'.format(session))
+ message=f'Saved session {session}.')
data = session.read_text(encoding='utf-8')
self._log('\nCurrent session data:\n' + data)
@@ -966,14 +966,14 @@ class QuteProc(testprocess.Process):
raise ValueError('Invalid response from qutebrowser: {}'
.format(message))
- def compare_session(self, expected):
+ def compare_session(self, expected, *, flags="--with-private"):
"""Compare the current sessions against the given template.
partial_compare is used, which means only the keys/values listed will
be compared.
"""
__tracebackhide__ = lambda e: e.errisinstance(pytest.fail.Exception)
- data = self.get_session()
+ data = self.get_session(flags=flags)
expected = yaml.load(expected, Loader=YamlLoader)
outcome = testutils.partial_compare(data, expected)
if not outcome:
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/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 595aa6426..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
@@ -1043,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()