diff options
53 files changed, 1116 insertions, 396 deletions
@@ -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:[<Alt-B>]+: +pass:[rl-backward-word]+ * +pass:[<Alt-Backspace>]+: +pass:[rl-backward-kill-word]+ * +pass:[<Alt-D>]+: +pass:[rl-kill-word]+ +* +pass:[<Alt-E>]+: +pass:[prompt-fileselect-external]+ * +pass:[<Alt-F>]+: +pass:[rl-forward-word]+ * +pass:[<Alt-Shift-Y>]+: +pass:[prompt-yank --sel]+ * +pass:[<Alt-Y>]+: +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() |