diff --git a/.flake8 b/.flake8 index 5b0360ab7..8828a3aa8 100644 --- a/.flake8 +++ b/.flake8 @@ -44,7 +44,7 @@ ignore = min-version = 3.4.0 max-complexity = 12 per-file-ignores = - /tests/*/test_*.py : D100,D101,D401 + /tests/**/test_*.py : D100,D101,D401 /tests/unit/browser/test_history.py : N806 /tests/helpers/fixtures.py : N806 /tests/unit/browser/webkit/http/test_content_disposition.py : D400 diff --git a/MANIFEST.in b/MANIFEST.in index 9dace6f98..242fea292 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,6 +8,7 @@ graft icons graft doc/img graft misc/apparmor graft misc/userscripts +graft misc/requirements recursive-include scripts *.py *.sh *.js include qutebrowser/utils/testfile include qutebrowser/git-commit-id @@ -32,8 +33,6 @@ include doc/qutebrowser.1.asciidoc include doc/changelog.asciidoc prune tests prune qutebrowser/3rdparty -prune misc/requirements -prune misc/docker exclude pytest.ini exclude qutebrowser.rcc exclude qutebrowser/javascript/.eslintrc.yaml diff --git a/README.asciidoc b/README.asciidoc index 8dfc11c9f..6656c2bbd 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -99,7 +99,7 @@ Requirements The following software and libraries are required to run qutebrowser: * http://www.python.org/[Python] 3.5 or newer (3.6 recommended) -* http://qt.io/[Qt] 5.7.1 or newer with the following modules: +* http://qt.io/[Qt] 5.7.1 or newer (5.10 recommended) with the following modules: - QtCore / qtbase - QtQuick (part of qtbase in some distributions) - QtSQL (part of qtbase in some distributions) @@ -109,7 +109,7 @@ The following software and libraries are required to run qutebrowser: link:https://github.com/annulen/webkit/wiki[updated fork] (5.212) is supported * http://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.7.0 or newer - (5.9.2 recommended) for Python 3 + (5.10 recommended) for Python 3 * https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools] * http://fdik.org/pyPEG/[pyPEG2] * http://jinja.pocoo.org/[jinja2] diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index f8daed78d..7569af8ef 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -18,13 +18,70 @@ breaking changes (such as renamed commands) can happen in minor releases. v1.3.0 (unreleased) ------------------- +Added +~~~~~ + +- New `:scroll-to-anchor` command to scroll to an anchor in the document. +- New `url.open_base_url` option to open the base URL of a searchengine when no + search term is given. +- New `tabs.min_width` setting to configure the minimal width for tabs. +- New `getbib` userscript to download bibtex information for DOIs on a page. + Changed ~~~~~~~ +- QtWebEngine: Support for JavaScript Shared Web Workers have been disabled on + Qt versions older than 5.11 because of security issues in in Chromium. + You can get the same effect in earlier versions via + `:set qt.args ['disable-shared-workers']`. An equivalent workaround is also + contained in Qt 5.9.5 and 5.10.1. +- The file dialog for downloads now has basic tab completion based on the + entered text. - `:version` now shows OS information for POSIX OS other than Linux/macOS. +- When there's an error inserting the text from an external editor, a backup + file is now saved. +- The `window.hide_wayland_decoration` setting got renamed to + `window.hide_decoration` and now also works outside of wayland. +- The `tabs.favicons.show` setting now can take three values: `'always'` (was + `True`), `'never'` (was `False`) and `'pinned'` (to only show favicons for + pinned tabs). +- Hover tooltips on tabs now always show the webpage's title. +- The default value for `content.host_blocking.lists` was changed to only + include https://github.com/StevenBlack/hosts[Steven Black's hosts-list] which + combines various sources. +- Error messages when trying to wrap when `tabs.wrap` is `False` are now logged + to debug instead of messages. -v1.2.1 (unreleased) -------------------- + +Fixed +~~~~~ + +- Using hints before a page is fully loaded is now possible again. +- Selecting hints with the number keypad now works again. +- Tab titles for tabs loaded from sessions should now really be correct instead + of showing the URL. +- Loading URLs with customized settings from a session now avoids an additional + reload. +- The window icon and title now get set correctly again. +- The `tabs.switching_delay` setting now has a correct maximum value limit set. +- The `taskadd` script now works properly when there's multi-line output. +- QtWebEngine: Worked around issues with GreaseMonkey/stylesheets not being + loaded correctly in some situations. +- The statusbar now more closely reflects the caret mode state. +- The icon on Windows should now be displayed in a higher resolution. +- The QtWebEngine development tools (inspector) now also work when JavaScript is + disabled globally. +- Building `.exe` files now works when `upx` is installed on the system. +- The keyhint widget now shows the correct text for chained modifiers. +- Loading GreaseMonkey scripts now also works with Jinja2 2.8 (e.g. on Debian + Stable). +- Adding styles with GreaseMonkey on fast sites now works properly. +- Window ID 0 is now excluded properly from `:tab-take` completion. +- A rare crash when cancelling a download has been fixed. +- The Makefile (intended for packagers) now supports `PREFIX` properly. + +v1.2.1 +------ Fixed ~~~~~ @@ -36,6 +93,15 @@ Fixed - With "tox -e mkvenv-pypi", PyQt 5.10.0 is used again instead of Qt 5.10.1, because of an issue with Qt 5.10.1 which causes qutebrowser to fail to start ("Could not find QtWebEngineProcess"). +- Unbinding keys which were bound in older qutebrowser versions now doesn't + crash anymore. +- Fixed a crash when reloading a page which wasn't fully loaded with v1.2.0 +- Keys on the numeric keypad now fall back to the same bindings without `Num+` + if no `Num+` binding was found. +- Fixed hinting on some pages with Qt < 5.10. +- Titles are now displayed correctly again for tabs which are cloned or loaded + from sessions. +- Shortcuts now correctly use `Ctrl` instead of `Command` on macOS again. v1.2.0 ------ diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc index 9937434b9..ad48c1324 100644 --- a/doc/contributing.asciidoc +++ b/doc/contributing.asciidoc @@ -670,10 +670,11 @@ qutebrowser release ~~~~~~~~~~~~~~~~~~~ * Make sure there are no unstaged changes and the tests are green. +* Make sure all issues with the related milestone are closed. * Run `x=... y=...` to set the respective shell variables. -* Adjust `__version_info__` in `qutebrowser/__init__.py`. * Update changelog (remove *(unreleased)*). +* Adjust `__version_info__` in `qutebrowser/__init__.py`. * Commit. * Create annotated git tag (`git tag -s "v1.$x.$y" -m "Release v1.$x.$y"`). @@ -683,9 +684,11 @@ qutebrowser release * Mark the milestone at https://github.com/qutebrowser/qutebrowser/milestones as closed. -* Linux: Run `git checkout v1.$x.$y && python3 scripts/dev/build_release.py --upload v1.$x.$y`. +* Linux: Run `git checkout v1.$x.$y && ./.venv/bin/python3 scripts/dev/build_release.py --upload v1.$x.$y`. * Windows: Run `git checkout v1.X.Y; C:\Python36-32\python scripts\dev\build_release.py --asciidoc C:\Python27\python C:\asciidoc-8.6.9\asciidoc.py --upload v1.X.Y` (replace X/Y by hand). * macOS: Run `git checkout v1.X.Y && python3 scripts/dev/build_release.py --upload v1.X.Y` (replace X/Y by hand). -* On server: Run `python3 scripts/dev/download_release.py v1.X.Y` (replace X/Y by hand). +* On server: + - Run `python3 scripts/dev/download_release.py v1.X.Y` (replace X/Y by hand). + - Run `git pull github master && sudo python3 scripts/asciidoc2html.py --website /srv/http/qutebrowser` * Update `qutebrowser-git` PKGBUILD if dependencies/install changed. * Announce to qutebrowser and qutebrowser-announce mailinglist. diff --git a/doc/faq.asciidoc b/doc/faq.asciidoc index 9b3f210ea..5fd36d67b 100644 --- a/doc/faq.asciidoc +++ b/doc/faq.asciidoc @@ -212,6 +212,37 @@ Why takes it longer to open an URL in qutebrowser than in chromium?:: qutebrowser if it is not running already. Also check if you want to use webengine as backend in line 17 and change it to your needs. + +How do I make qutebrowser use greasemonkey scripts?:: + There is currently no UI elements to handle managing greasemonkey scripts. + All management of what scripts are installed or disabled is done in the + filesystem by you. qutebrowser reads all files that have an extension of + `.js` from the `/greasemonkey/` folder and attempts to load them. + Where `` is the qutebrowser data directory shown in the `Paths` + section of the page displayed by `:version`. If you want to disable a + script just rename it, for example, to have `.disabled` on the end, after + the `.js` extension. To reload scripts from that directory run the command + `:greasemonkey-reload`. ++ +Troubleshooting: to check that your script is being loaded when +`:greasemonkey-reload` runs you can start qutebrowser with the arguments +`--debug --logfilter greasemonkey,js` and check the messages on the +program's standard output for errors parsing or loading your script. +You may also see javascript errors if your script is expecting an environment +that we fail to provide. ++ +Note that there are some missing features which you may run into: + +. Some scripts expect `GM_xmlhttpRequest` to ignore Cross Origin Resource + Sharing restrictions, this is currently not supported, so scripts making + requests to third party sites will often fail to function correctly. +. If your backend is a QtWebEngine version 5.8, 5.9 or 5.10 then regular + expressions are not supported in `@include` or `@exclude` rules. If your + script uses them you can re-write them to use glob expressions or convert + them to `@match` rules. + See https://wiki.greasespot.net/Metadata_Block[the wiki] for more info. +. Any greasemonkey API function to do with adding UI elements is not currently + supported. That means context menu extentensions and background pages. == Troubleshooting diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 3a12f08eb..31ed75486 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -93,6 +93,7 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Scroll the current tab in the given direction. |<>|Scroll the frame page-wise. |<>|Scroll the current tab by 'count * dx/dy' pixels. +|<>|Scroll to the given anchor in the document. |<>|Scroll to a specific percentage of the page. |<>|Search for a text on the current page. With no text, clear results. |<>|Continue the search to the ([count]th) next term. @@ -1024,6 +1025,15 @@ Scroll the current tab by 'count * dx/dy' pixels. ==== count multiplier +[[scroll-to-anchor]] +=== scroll-to-anchor +Syntax: +:scroll-to-anchor 'name'+ + +Scroll to the given anchor in the document. + +==== positional arguments +* +'name'+: The anchor to scroll to. + [[scroll-to-perc]] === scroll-to-perc Syntax: +:scroll-to-perc [*--horizontal*] ['perc']+ diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 266315d56..3c686d15a 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -242,10 +242,10 @@ To suppress loading of any default keybindings, you can set Loading `autoconfig.yml` ~~~~~~~~~~~~~~~~~~~~~~~~ -By default, all customization done via `:set`, `:bind` and `:unbind` is -temporary as soon as a `config.py` exists. The settings done that way are always -saved in the `autoconfig.yml` file, but you'll need to explicitly load it in -your `config.py` by doing: +All customization done via the UI (`:set`, `:bind` and `:unbind`) is +stored in the `autoconfig.yml` file, which is not loaded automatically as soon +as a `config.py` exists. If you want those settings to be loaded, you'll need to +explicitly load the `autoconfig.yml` file in your `config.py` by doing: .config.py: [source,python] diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index a428d65bd..1e58ffa87 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -236,10 +236,11 @@ |<>|Mouse button with which to close tabs. |<>|How to behave when the close mouse button is pressed on the tab bar. |<>|Scaling factor for favicons in the tab bar. -|<>|Show favicons in the tab bar. +|<>|When to show favicons in the tab bar. |<>|Padding (in pixels) for tab indicators. |<>|Width (in pixels) of the progress indicator (0 to disable). |<>|How to behave when the last tab is closed. +|<>|Minimum width (in pixels) of tabs (-1 for the default minimum size behavior). |<>|When switching tabs, what input mode is applied. |<>|Switch between tabs using the mouse wheel. |<>|Position of new tabs opened from another tab. @@ -259,10 +260,11 @@ |<>|What search to start when something else than a URL is entered. |<>|Page to open if :open -t/-b/-w is used without URL. |<>|URL segments where `:navigate increment/decrement` will search for a number. +|<>|Open base URL of the searchengine if a searchengine shortcut is invoked without parameters. |<>|Search engines which can be used via the address bar. |<>|Page(s) to open at the start. |<>|URL parameters to strip with `:yank url`. -|<>|Hide the window decoration when using wayland. +|<>|Hide the window decoration. |<>|Format to use for the window title. The same placeholders like for |<>|Default zoom level. |<>|Available zoom levels. @@ -1653,11 +1655,7 @@ Type: <> Default: -- +pass:[https://www.malwaredomainlist.com/hostslist/hosts.txt]+ -- +pass:[http://someonewhocares.org/hosts/hosts]+ -- +pass:[http://winhelp2002.mvps.org/hosts.zip]+ -- +pass:[http://malwaredomains.lehigh.edu/files/justdomains.zip]+ -- +pass:[https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&mimetype=plaintext]+ +- +pass:[https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts]+ [[content.host_blocking.whitelist]] === content.host_blocking.whitelist @@ -2823,11 +2821,17 @@ Default: +pass:[1.0]+ [[tabs.favicons.show]] === tabs.favicons.show -Show favicons in the tab bar. +When to show favicons in the tab bar. -Type: <> +Type: <> -Default: +pass:[true]+ +Valid values: + + * +always+: Always show favicons. + * +never+: Always hide favicons. + * +pinned+: Show favicons only on pinned tabs. + +Default: +pass:[always]+ [[tabs.indicator.padding]] === tabs.indicator.padding @@ -2866,6 +2870,16 @@ Valid values: Default: +pass:[ignore]+ +[[tabs.min_width]] +=== tabs.min_width +Minimum width (in pixels) of tabs (-1 for the default minimum size behavior). +This setting only applies when tabs are horizontal. +This setting does not apply to pinned tabs, unless `tabs.pinned.shrink` is False. + +Type: <> + +Default: +pass:[-1]+ + [[tabs.mode_on_change]] === tabs.mode_on_change When switching tabs, what input mode is applied. @@ -3102,6 +3116,14 @@ Default: - +pass:[path]+ - +pass:[query]+ +[[url.open_base_url]] +=== url.open_base_url +Open base URL of the searchengine if a searchengine shortcut is invoked without parameters. + +Type: <> + +Default: +pass:[false]+ + [[url.searchengines]] === url.searchengines Search engines which can be used via the address bar. @@ -3137,10 +3159,12 @@ Default: - +pass:[utm_term]+ - +pass:[utm_content]+ -[[window.hide_wayland_decoration]] -=== window.hide_wayland_decoration -Hide the window decoration when using wayland. -This setting requires a restart. +[[window.hide_decoration]] +=== window.hide_decoration +Hide the window decoration. + +This setting requires a restart on Wayland. + Type: <> @@ -3273,7 +3297,7 @@ See the setting's valid values for more information on allowed values. |TextAlignment|Alignment of text. |TimestampTemplate|An strftime-like template for timestamps. -See https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior for reference. +See https://sqlite.org/lang_datefunc.html for reference. |UniqueCharString|A string which may not contain duplicate chars. |Url|A URL as a string. |VerticalPosition|The position of the download bar. diff --git a/doc/img/cheatsheet-big.png b/doc/img/cheatsheet-big.png index bc4b3e053..57a0c4448 100644 Binary files a/doc/img/cheatsheet-big.png and b/doc/img/cheatsheet-big.png differ diff --git a/doc/img/cheatsheet-small.png b/doc/img/cheatsheet-small.png index 06432b086..461c04c8c 100644 Binary files a/doc/img/cheatsheet-small.png and b/doc/img/cheatsheet-small.png differ diff --git a/doc/install.asciidoc b/doc/install.asciidoc index c48fc71d6..411675bfa 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -47,17 +47,26 @@ Debian Stretch / Ubuntu 17.04 and 17.10 Those versions come with QtWebEngine in the repositories. This makes it possible to install qutebrowser via the Debian package. -Download the https://packages.debian.org/sid/all/qutebrowser/download[qutebrowser] and -https://packages.debian.org/sid/all/python3-pypeg2/download[PyPEG2] -package from the Debian repositories. +You'll need to download three packages: -Install the packages: +- https://packages.debian.org/sid/all/python3-pypeg2/download[PyPEG2] (a library + used by qutebrowser which is not in the earlier repositories) +- https://packages.debian.org/sid/all/qutebrowser/download[qutebrowser] itself +- Either https://packages.debian.org/sid/all/qutebrowser-qtwebengine/download[qutebrowser-qtwebengine] + or https://packages.debian.org/sid/all/qutebrowser-qtwebkit/download[qutebrowser-qtwebkit] + (or both) depending on the backend you want to use. QtWebEngine is the + default/recommended choice. + +After downloading, install the packages: ---- # apt install ./python3-pypeg2_*_all.deb -# apt install ./qutebrowser_*_all.deb +# apt install ./qutebrowser*.deb ---- +For an update after the initial install, you only need to download/install the +qutebrowser package. + Debian Testing / Ubuntu 18.04 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -417,7 +426,11 @@ Creating a wrapper script ~~~~~~~~~~~~~~~~~~~~~~~~~ Running `tox` does not install a system-wide `qutebrowser` script. You can -launch qutebrowser by doing `.venv/bin/python3 -m qutebrowser`. +launch qutebrowser by doing: + +---- +.venv/bin/python3 -m qutebrowser +---- You can create a simple wrapper script to start qutebrowser somewhere in your `$PATH` (e.g. `/usr/local/bin/qutebrowser` or `~/bin/qutebrowser`): diff --git a/icons/qutebrowser.ico b/icons/qutebrowser.ico index f1831adad..42419d944 100644 Binary files a/icons/qutebrowser.ico and b/icons/qutebrowser.ico differ diff --git a/misc/Makefile b/misc/Makefile index 714223d10..6a6762d6a 100644 --- a/misc/Makefile +++ b/misc/Makefile @@ -1,25 +1,31 @@ PYTHON = python3 -DESTDIR = / +PREFIX = /usr/local +DESTDIR = ICONSIZES = 16 24 32 48 64 128 256 512 +SETUPTOOLSOPTIONS = +ifdef DESTDIR +SETUPTOOLSOPTS = --root="$(DESTDIR)" +endif + .PHONY: install doc/qutebrowser.1.html: a2x -f manpage doc/qutebrowser.1.asciidoc install: doc/qutebrowser.1.html - $(PYTHON) setup.py install --root="$(DESTDIR)" --optimize=1 + $(PYTHON) setup.py install --prefix="$(PREFIX)" --optimize=1 $(SETUPTOOLSOPTS) install -Dm644 doc/qutebrowser.1 \ - "$(DESTDIR)/usr/share/man/man1/qutebrowser.1" + "$(DESTDIR)$(PREFIX)/share/man/man1/qutebrowser.1" install -Dm644 misc/qutebrowser.desktop \ - "$(DESTDIR)/usr/share/applications/qutebrowser.desktop" + "$(DESTDIR)$(PREFIX)/share/applications/qutebrowser.desktop" $(foreach i,$(ICONSIZES),install -Dm644 "icons/qutebrowser-$(i)x$(i).png" \ - "$(DESTDIR)/usr/share/icons/hicolor/$(i)x$(i)/apps/qutebrowser.png";) + "$(DESTDIR)$(PREFIX)/share/icons/hicolor/$(i)x$(i)/apps/qutebrowser.png";) install -Dm644 icons/qutebrowser.svg \ - "$(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qutebrowser.svg" - install -Dm755 -t "$(DESTDIR)/usr/share/qutebrowser/userscripts/" \ + "$(DESTDIR)$(PREFIX)/share/icons/hicolor/scalable/apps/qutebrowser.svg" + install -Dm755 -t "$(DESTDIR)$(PREFIX)/share/qutebrowser/userscripts/" \ $(wildcard misc/userscripts/*) - install -Dm755 -t "$(DESTDIR)/usr/share/qutebrowser/scripts/" \ + install -Dm755 -t "$(DESTDIR)$(PREFIX)/share/qutebrowser/scripts/" \ $(filter-out scripts/__init__.py scripts/__pycache__ scripts/dev \ scripts/testbrowser scripts/asciidoc2html.py scripts/setupcommon.py \ scripts/link_pyqt.py,$(wildcard scripts/*)) diff --git a/misc/cheatsheet.svg b/misc/cheatsheet.svg index a525ab955..bb87142a4 100644 --- a/misc/cheatsheet.svg +++ b/misc/cheatsheet.svg @@ -33,7 +33,7 @@ inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="1.7536248" - inkscape:cx="376.55567" + inkscape:cx="430.72917" inkscape:cy="268.64059" inkscape:document-units="px" inkscape:current-layer="layer1" @@ -3710,7 +3710,7 @@ style="font-weight:bold;font-size:10.66666698px;line-height:1.25;font-family:sans-serif;-inkscape-font-specification:'Sans Bold';fill:#000000;stroke-width:1.06666672" id="flowPara5701-9-2">(10) toggling settings:(12) toggling settings:tsh - toggle scripts for the current host (temporarily)\"\<)])+)') +# https://stackoverflow.com/a/10324802/3865876, too strict +# dval = re.compile(r'\b(10[.][0-9]{4,}(?:[.][0-9]+)*/(?:(?!["&\'<>])\S)+)\b') +dois = dval.findall(text) +dois = Counter(e[0] for e in dois) +try: + doi = dois.most_common(1)[0][0] +except IndexError: + message_fifo("No DOIs found on page") + sys.exit() +message_fifo("Found {} DOIs on page, selecting {}".format(len(dois), doi), + level="info") + +# get bibtex data corresponding to DOI +url = "http://dx.doi.org/" + url_parse.quote(doi) +headers = dict(Accept='text/bibliography; style=bibtex') +request = url_request.Request(url, headers=headers) +response = url_request.urlopen(request) +status_code = response.getcode() +if status_code >= 400: + message_fifo("Request returned {}".format(status_code)) + sys.exit() + +# obtain content and format it +bibtex = response.read().decode("utf-8").strip() +bibtex = bibtex.replace(" ", "\n ", 1).\ + replace("}, ", "},\n ").replace("}}", "}\n}") + +# append to file +bib_filepath = os.getenv("QUTE_BIB_FILEPATH", "/tmp/qute.bib") +with open(bib_filepath, "a") as f: + f.write(bibtex + "\n\n") diff --git a/misc/userscripts/taskadd b/misc/userscripts/taskadd index b1ded245c..36e1c2ced 100755 --- a/misc/userscripts/taskadd +++ b/misc/userscripts/taskadd @@ -28,7 +28,7 @@ if msg="$(task add "$title" "$*" 2>&1)"; then # annotate the new task with the url, send the output back to the browser task +LATEST annotate "$QUTE_URL" - echo "message-info '$msg'" >> "$QUTE_FIFO" + echo "message-info '$(echo "$msg" | head -n 1)'" >> "$QUTE_FIFO" else - echo "message-error '$msg'" >> "$QUTE_FIFO" + echo "message-error '$(echo "$msg" | head -n 1)'" >> "$QUTE_FIFO" fi diff --git a/qutebrowser/__init__.py b/qutebrowser/__init__.py index 84831daaa..31fd5983f 100644 --- a/qutebrowser/__init__.py +++ b/qutebrowser/__init__.py @@ -26,7 +26,7 @@ __copyright__ = "Copyright 2014-2018 Florian Bruhin (The Compiler)" __license__ = "GPL" __maintainer__ = __author__ __email__ = "mail@qutebrowser.org" -__version_info__ = (1, 2, 0) +__version_info__ = (1, 2, 1) __version__ = '.'.join(str(e) for e in __version_info__) __description__ = "A keyboard-driven, vim-like browser based on PyQt5." diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 014e02c5f..3cce2e85e 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -340,7 +340,7 @@ def _open_startpage(win_id=None): for cur_win_id in list(window_ids): # Copying as the dict could change tabbed_browser = objreg.get('tabbed-browser', scope='window', window=cur_win_id) - if tabbed_browser.count() == 0: + if tabbed_browser.widget.count() == 0: log.init.debug("Opening start pages") for url in config.val.url.start_pages: tabbed_browser.tabopen(url) diff --git a/qutebrowser/browser/adblock.py b/qutebrowser/browser/adblock.py index f0462a778..f42d1a1db 100644 --- a/qutebrowser/browser/adblock.py +++ b/qutebrowser/browser/adblock.py @@ -94,14 +94,8 @@ class HostBlocker: _done_count: How many files have been read successfully. _local_hosts_file: The path to the blocked-hosts file. _config_hosts_file: The path to a blocked-hosts in ~/.config - - Class attributes: - WHITELISTED: Hosts which never should be blocked. """ - WHITELISTED = ('localhost', 'localhost.localdomain', 'broadcasthost', - 'local') - def __init__(self): self._blocked_hosts = set() self._config_blocked_hosts = set() @@ -234,16 +228,14 @@ class HostBlocker: parts = line.split() if len(parts) == 1: # "one host per line" format - host = parts[0] - elif len(parts) == 2: - # /etc/hosts format - host = parts[1] + hosts = [parts[0]] else: - log.misc.error("Failed to parse: {!r}".format(line)) - return False + # /etc/hosts format + hosts = parts[1:] - if host not in self.WHITELISTED: - self._blocked_hosts.add(host) + for host in hosts: + if '.' in host and not host.endswith('.localdomain'): + self._blocked_hosts.add(host) return True diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index f04fa5c24..aea15a257 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -114,6 +114,10 @@ class TabData: netrc_used = attr.ib(False) input_mode = attr.ib(usertypes.KeyMode.normal) + def should_show_icon(self): + return (config.val.tabs.favicons.show == 'always' or + config.val.tabs.favicons.show == 'pinned' and self.pinned) + class AbstractAction: @@ -333,7 +337,14 @@ class AbstractZoom(QObject): class AbstractCaret(QObject): - """Attribute of AbstractTab for caret browsing.""" + """Attribute of AbstractTab for caret browsing. + + Signals: + selection_toggled: Emitted when the selection was toggled. + arg: Whether the selection is now active. + """ + + selection_toggled = pyqtSignal(bool) def __init__(self, tab, mode_manager, parent=None): super().__init__(parent) @@ -439,6 +450,9 @@ class AbstractScroller(QObject): def to_point(self, point): raise NotImplementedError + def to_anchor(self, name): + raise NotImplementedError + def delta(self, x=0, y=0): raise NotImplementedError @@ -665,8 +679,7 @@ class AbstractTab(QWidget): objreg.register('hintmanager', hintmanager, scope='tab', window=self.win_id, tab=self.tab_id) - self.predicted_navigation.connect( - lambda url: self.title_changed.emit(url.toDisplayString())) + self.predicted_navigation.connect(self._on_predicted_navigation) def _set_widget(self, widget): # pylint: disable=protected-access @@ -715,6 +728,14 @@ class AbstractTab(QWidget): evt.posted = True QApplication.postEvent(recipient, evt) + @pyqtSlot(QUrl) + def _on_predicted_navigation(self, url): + """Adjust the title if we are going to visit an URL soon.""" + qtutils.ensure_valid(url) + url_string = url.toDisplayString() + log.webview.debug("Predicted navigation: {}".format(url_string)) + self.title_changed.emit(url_string) + @pyqtSlot(QUrl) def _on_url_changed(self, url): """Update title when URL has changed and no title is available.""" @@ -815,11 +836,12 @@ class AbstractTab(QWidget): def load_status(self): return self._load_status - def _openurl_prepare(self, url): + def _openurl_prepare(self, url, *, predict=True): qtutils.ensure_valid(url) - self.predicted_navigation.emit(url) + if predict: + self.predicted_navigation.emit(url) - def openurl(self, url): + def openurl(self, url, *, predict=True): raise NotImplementedError def reload(self, *, force=False): diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index b84bde7a8..60844029f 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -53,7 +53,6 @@ class CommandDispatcher: cmdutils.register() decorators are run, currentWidget() will return None. Attributes: - _editor: The ExternalEditor object. _win_id: The window ID the CommandDispatcher is associated with. _tabbed_browser: The TabbedBrowser used. """ @@ -73,16 +72,16 @@ class CommandDispatcher: def _count(self): """Convenience method to get the widget count.""" - return self._tabbed_browser.count() + return self._tabbed_browser.widget.count() def _set_current_index(self, idx): """Convenience method to set the current widget index.""" cmdutils.check_overflow(idx, 'int') - self._tabbed_browser.setCurrentIndex(idx) + self._tabbed_browser.widget.setCurrentIndex(idx) def _current_index(self): """Convenience method to get the current widget index.""" - return self._tabbed_browser.currentIndex() + return self._tabbed_browser.widget.currentIndex() def _current_url(self): """Convenience method to get the current url.""" @@ -101,7 +100,7 @@ class CommandDispatcher: def _current_widget(self): """Get the currently active widget from a command.""" - widget = self._tabbed_browser.currentWidget() + widget = self._tabbed_browser.widget.currentWidget() if widget is None: raise cmdexc.CommandError("No WebView available yet!") return widget @@ -147,10 +146,10 @@ class CommandDispatcher: None if no widget was found. """ if count is None: - return self._tabbed_browser.currentWidget() + return self._tabbed_browser.widget.currentWidget() elif 1 <= count <= self._count(): cmdutils.check_overflow(count + 1, 'int') - return self._tabbed_browser.widget(count - 1) + return self._tabbed_browser.widget.widget(count - 1) else: return None @@ -163,7 +162,7 @@ class CommandDispatcher: if not show_error: return raise cmdexc.CommandError("No last focused tab!") - idx = self._tabbed_browser.indexOf(tab) + idx = self._tabbed_browser.widget.indexOf(tab) if idx == -1: raise cmdexc.CommandError("Last focused tab vanished!") self._set_current_index(idx) @@ -212,7 +211,7 @@ class CommandDispatcher: what's configured in 'tabs.select_on_remove'. count: The tab index to close, or None """ - tabbar = self._tabbed_browser.tabBar() + tabbar = self._tabbed_browser.widget.tabBar() selection_override = self._get_selection_override(prev, next_, opposite) @@ -264,7 +263,7 @@ class CommandDispatcher: return to_pin = not tab.data.pinned - self._tabbed_browser.set_tab_pinned(tab, to_pin) + self._tabbed_browser.widget.set_tab_pinned(tab, to_pin) @cmdutils.register(instance='command-dispatcher', name='open', maxsplit=0, scope='window') @@ -483,7 +482,8 @@ class CommandDispatcher: """ cmdutils.check_exclusive((bg, window), 'bw') curtab = self._current_widget() - cur_title = self._tabbed_browser.page_title(self._current_index()) + cur_title = self._tabbed_browser.widget.page_title( + self._current_index()) try: history = curtab.history.serialize() except browsertab.WebTabError as e: @@ -499,18 +499,18 @@ class CommandDispatcher: newtab = new_tabbed_browser.tabopen(background=bg) new_tabbed_browser = objreg.get('tabbed-browser', scope='window', window=newtab.win_id) - idx = new_tabbed_browser.indexOf(newtab) + idx = new_tabbed_browser.widget.indexOf(newtab) - new_tabbed_browser.set_page_title(idx, cur_title) - if config.val.tabs.favicons.show: - new_tabbed_browser.setTabIcon(idx, curtab.icon()) + new_tabbed_browser.widget.set_page_title(idx, cur_title) + if curtab.data.should_show_icon(): + new_tabbed_browser.widget.setTabIcon(idx, curtab.icon()) if config.val.tabs.tabs_are_windows: - new_tabbed_browser.window().setWindowIcon(curtab.icon()) + new_tabbed_browser.widget.window().setWindowIcon(curtab.icon()) newtab.data.keep_icon = True newtab.history.deserialize(history) newtab.zoom.set_factor(curtab.zoom.factor()) - new_tabbed_browser.set_tab_pinned(newtab, curtab.data.pinned) + new_tabbed_browser.widget.set_tab_pinned(newtab, curtab.data.pinned) return newtab @cmdutils.register(instance='command-dispatcher', scope='window') @@ -768,6 +768,15 @@ class CommandDispatcher: self._current_widget().scroller.to_perc(x, y) + @cmdutils.register(instance='command-dispatcher', scope='window') + def scroll_to_anchor(self, name): + """Scroll to the given anchor in the document. + + Args: + name: The anchor to scroll to. + """ + self._current_widget().scroller.to_anchor(name) + @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', count=True) @cmdutils.argument('top_navigate', metavar='ACTION', @@ -846,7 +855,7 @@ class CommandDispatcher: keep: Stay in visual mode after yanking the selection. """ if what == 'title': - s = self._tabbed_browser.page_title(self._current_index()) + s = self._tabbed_browser.widget.page_title(self._current_index()) elif what == 'domain': port = self._current_url().port() s = '{}://{}{}'.format(self._current_url().scheme(), @@ -958,7 +967,7 @@ class CommandDispatcher: force: Avoid confirmation for pinned tabs. """ cmdutils.check_exclusive((prev, next_), 'pn') - cur_idx = self._tabbed_browser.currentIndex() + cur_idx = self._tabbed_browser.widget.currentIndex() assert cur_idx != -1 def _to_close(i): @@ -1013,7 +1022,7 @@ class CommandDispatcher: elif config.val.tabs.wrap: self._set_current_index(newidx % self._count()) else: - raise cmdexc.CommandError("First tab") + log.webview.debug("First tab") @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', count=True) @@ -1033,7 +1042,7 @@ class CommandDispatcher: elif config.val.tabs.wrap: self._set_current_index(newidx % self._count()) else: - raise cmdexc.CommandError("Last tab") + log.webview.debug("Last tab") def _resolve_buffer_index(self, index): """Resolve a buffer index to the tabbedbrowser and tab. @@ -1075,11 +1084,11 @@ class CommandDispatcher: tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - if not 0 < idx <= tabbed_browser.count(): + if not 0 < idx <= tabbed_browser.widget.count(): raise cmdexc.CommandError( "There's no tab with index {}!".format(idx)) - return (tabbed_browser, tabbed_browser.widget(idx-1)) + return (tabbed_browser, tabbed_browser.widget.widget(idx-1)) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) @@ -1107,10 +1116,10 @@ class CommandDispatcher: tabbed_browser, tab = self._resolve_buffer_index(index) - window = tabbed_browser.window() + window = tabbed_browser.widget.window() window.activateWindow() window.raise_() - tabbed_browser.setCurrentWidget(tab) + tabbed_browser.widget.setCurrentWidget(tab) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('index', choices=['last']) @@ -1194,7 +1203,7 @@ class CommandDispatcher: cur_idx = self._current_index() cmdutils.check_overflow(cur_idx, 'int') cmdutils.check_overflow(new_idx, 'int') - self._tabbed_browser.tabBar().moveTab(cur_idx, new_idx) + self._tabbed_browser.widget.tabBar().moveTab(cur_idx, new_idx) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_replace_variables=True) @@ -1278,10 +1287,10 @@ class CommandDispatcher: idx = self._current_index() if idx != -1: - env['QUTE_TITLE'] = self._tabbed_browser.page_title(idx) + env['QUTE_TITLE'] = self._tabbed_browser.widget.page_title(idx) # FIXME:qtwebengine: If tab is None, run_async will fail! - tab = self._tabbed_browser.currentWidget() + tab = self._tabbed_browser.widget.currentWidget() try: url = self._tabbed_browser.current_url() @@ -1639,7 +1648,7 @@ class CommandDispatcher: ed = editor.ExternalEditor(watch=True, parent=self._tabbed_browser) ed.file_updated.connect(functools.partial( - self.on_file_updated, elem)) + self.on_file_updated, ed, elem)) ed.editing_finished.connect(lambda: mainwindow.raise_window( objreg.last_focused_window(), alert=False)) ed.edit(text, caret_position) @@ -1654,7 +1663,7 @@ class CommandDispatcher: tab = self._current_widget() tab.elements.find_focused(self._open_editor_cb) - def on_file_updated(self, elem, text): + def on_file_updated(self, ed, elem, text): """Write the editor text into the form field and clean up tempfile. Callback for GUIProcess when the edited text was updated. @@ -1667,8 +1676,10 @@ class CommandDispatcher: elem.set_value(text) except webelem.OrphanedError as e: message.error('Edited element vanished') + ed.backup() except webelem.Error as e: - raise cmdexc.CommandError(str(e)) + message.error(str(e)) + ed.backup() @cmdutils.register(instance='command-dispatcher', maxsplit=0, scope='window') @@ -2217,5 +2228,5 @@ class CommandDispatcher: pass return - window = self._tabbed_browser.window() + window = self._tabbed_browser.widget.window() window.setWindowState(window.windowState() ^ Qt.WindowFullScreen) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index 6879f4cf6..a43644bf6 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -30,7 +30,8 @@ import textwrap import attr from PyQt5.QtCore import pyqtSignal, QObject, QUrl -from qutebrowser.utils import log, standarddir, jinja, objreg, utils +from qutebrowser.utils import (log, standarddir, jinja, objreg, utils, + javascript) from qutebrowser.commands import cmdutils from qutebrowser.browser import downloads @@ -91,7 +92,7 @@ class GreasemonkeyScript: props = "" script = cls(re.findall(cls.PROPS_REGEX, props), source) script.script_meta = props - if not props: + if not script.includes: script.includes = ['*'] return script @@ -104,12 +105,13 @@ class GreasemonkeyScript: browser's debugger/inspector will not match up to the line numbers in the source script directly. """ - return jinja.js_environment.get_template( - 'greasemonkey_wrapper.js').render( - scriptName="/".join([self.namespace or '', self.name]), - scriptInfo=self._meta_json(), - scriptMeta=self.script_meta, - scriptSource=self._code) + template = jinja.js_environment.get_template('greasemonkey_wrapper.js') + return template.render( + scriptName=javascript.string_escape( + "/".join([self.namespace or '', self.name])), + scriptInfo=self._meta_json(), + scriptMeta=javascript.string_escape(self.script_meta), + scriptSource=self._code) def _meta_json(self): return json.dumps({ diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 48a0193e6..f7bcd713c 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -682,7 +682,7 @@ class HintManager(QObject): """ tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) - tab = tabbed_browser.currentWidget() + tab = tabbed_browser.widget.currentWidget() if tab is None: raise cmdexc.CommandError("No WebView available yet!") diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index 82996a803..4e992b172 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -162,6 +162,7 @@ class DownloadItem(downloads.AbstractDownloadItem): QTimer.singleShot(0, lambda: self._die(reply.errorString())) def _do_cancel(self): + self._read_timer.stop() if self._reply is not None: self._reply.finished.disconnect(self._on_reply_finished) self._reply.abort() diff --git a/qutebrowser/browser/signalfilter.py b/qutebrowser/browser/signalfilter.py index 663aa67e7..7cc46abdb 100644 --- a/qutebrowser/browser/signalfilter.py +++ b/qutebrowser/browser/signalfilter.py @@ -76,11 +76,11 @@ class SignalFilter(QObject): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) try: - tabidx = tabbed_browser.indexOf(tab) + tabidx = tabbed_browser.widget.indexOf(tab) except RuntimeError: # The tab has been deleted already return - if tabidx == tabbed_browser.currentIndex(): + if tabidx == tabbed_browser.widget.currentIndex(): if log_signal: log.signals.debug("emitting: {} (tab {})".format( debug.dbg_signal(signal, args), tabidx)) diff --git a/qutebrowser/browser/webengine/webengineinspector.py b/qutebrowser/browser/webengine/webengineinspector.py index 9200e3eb3..0145ad634 100644 --- a/qutebrowser/browser/webengine/webengineinspector.py +++ b/qutebrowser/browser/webengine/webengineinspector.py @@ -22,7 +22,7 @@ import os from PyQt5.QtCore import QUrl -from PyQt5.QtWebEngineWidgets import QWebEngineView +from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings from qutebrowser.browser import inspector @@ -35,6 +35,8 @@ class WebEngineInspector(inspector.AbstractWebInspector): super().__init__(parent) self.port = None view = QWebEngineView() + settings = view.settings() + settings.setAttribute(QWebEngineSettings.JavascriptEnabled, True) self._set_widget(view) def inspect(self, _page): diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 46bfcab59..417465929 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -26,16 +26,12 @@ Module attributes: import os -import sip from PyQt5.QtGui import QFont -from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile, - QWebEngineScript) +from PyQt5.QtWebEngineWidgets import QWebEngineSettings, QWebEngineProfile -from qutebrowser.browser import shared from qutebrowser.browser.webengine import spell from qutebrowser.config import config, websettings -from qutebrowser.utils import (utils, standarddir, javascript, qtutils, - message, log, objreg) +from qutebrowser.utils import utils, standarddir, qtutils, message, log # The default QWebEngineProfile default_profile = None @@ -169,133 +165,92 @@ class WebEngineSettings(websettings.AbstractSettings): self._ATTRIBUTES[name] = [value] -def _init_stylesheet(profile): - """Initialize custom stylesheets. +class ProfileSetter: - Partially inspired by QupZilla: - https://github.com/QupZilla/qupzilla/blob/v2.0/src/lib/app/mainapplication.cpp#L1063-L1101 - """ - old_script = profile.scripts().findScript('_qute_stylesheet') - if not old_script.isNull(): - profile.scripts().remove(old_script) + """Helper to set various settings on a profile.""" - css = shared.get_user_stylesheet() - source = '\n'.join([ - '"use strict";', - 'window._qutebrowser = window._qutebrowser || {};', - utils.read_file('javascript/stylesheet.js'), - javascript.assemble('stylesheet', 'set_css', css), - ]) + def __init__(self, profile): + self._profile = profile - script = QWebEngineScript() - script.setName('_qute_stylesheet') - script.setInjectionPoint(QWebEngineScript.DocumentCreation) - script.setWorldId(QWebEngineScript.ApplicationWorld) - script.setRunsOnSubFrames(True) - script.setSourceCode(source) - profile.scripts().insert(script) + def init_profile(self): + """Initialize settings on the given profile.""" + self.set_http_headers() + self.set_http_cache_size() + self._profile.settings().setAttribute( + QWebEngineSettings.FullScreenSupportEnabled, True) + if qtutils.version_check('5.8'): + self._profile.setSpellCheckEnabled(True) + self.set_dictionary_language() + def set_http_headers(self): + """Set the user agent and accept-language for the given profile. -def _update_stylesheet(): - """Update the custom stylesheet in existing tabs.""" - css = shared.get_user_stylesheet() - code = javascript.assemble('stylesheet', 'set_css', css) - for win_id, window in objreg.window_registry.items(): - # We could be in the middle of destroying a window here - if sip.isdeleted(window): - continue - tab_registry = objreg.get('tab-registry', scope='window', - window=win_id) - for tab in tab_registry.values(): - tab.run_js_async(code) + We override those per request in the URL interceptor (to allow for + per-domain values), but this one still gets used for things like + window.navigator.userAgent/.languages in JS. + """ + self._profile.setHttpUserAgent(config.val.content.headers.user_agent) + accept_language = config.val.content.headers.accept_language + if accept_language is not None: + self._profile.setHttpAcceptLanguage(accept_language) + def set_http_cache_size(self): + """Initialize the HTTP cache size for the given profile.""" + size = config.val.content.cache.size + if size is None: + size = 0 + else: + size = qtutils.check_overflow(size, 'int', fatal=False) -def _set_http_headers(profile): - """Set the user agent and accept-language for the given profile. + # 0: automatically managed by QtWebEngine + self._profile.setHttpCacheMaximumSize(size) - We override those per request in the URL interceptor (to allow for - per-domain values), but this one still gets used for things like - window.navigator.userAgent/.languages in JS. - """ - profile.setHttpUserAgent(config.val.content.headers.user_agent) - accept_language = config.val.content.headers.accept_language - if accept_language is not None: - profile.setHttpAcceptLanguage(accept_language) + def set_persistent_cookie_policy(self): + """Set the HTTP Cookie size for the given profile.""" + assert not self._profile.isOffTheRecord() + if config.val.content.cookies.store: + value = QWebEngineProfile.AllowPersistentCookies + else: + value = QWebEngineProfile.NoPersistentCookies + self._profile.setPersistentCookiesPolicy(value) + def set_dictionary_language(self, warn=True): + """Load the given dictionaries.""" + filenames = [] + for code in config.val.spellcheck.languages or []: + local_filename = spell.local_filename(code) + if not local_filename: + if warn: + message.warning("Language {} is not installed - see " + "scripts/dictcli.py in qutebrowser's " + "sources".format(code)) + continue -def _set_http_cache_size(profile): - """Initialize the HTTP cache size for the given profile.""" - size = config.val.content.cache.size - if size is None: - size = 0 - else: - size = qtutils.check_overflow(size, 'int', fatal=False) + filenames.append(local_filename) - # 0: automatically managed by QtWebEngine - profile.setHttpCacheMaximumSize(size) - - -def _set_persistent_cookie_policy(profile): - """Set the HTTP Cookie size for the given profile.""" - if config.val.content.cookies.store: - value = QWebEngineProfile.AllowPersistentCookies - else: - value = QWebEngineProfile.NoPersistentCookies - profile.setPersistentCookiesPolicy(value) - - -def _set_dictionary_language(profile, warn=True): - filenames = [] - for code in config.val.spellcheck.languages or []: - local_filename = spell.local_filename(code) - if not local_filename: - if warn: - message.warning( - "Language {} is not installed - see scripts/dictcli.py " - "in qutebrowser's sources".format(code)) - continue - - filenames.append(local_filename) - - log.config.debug("Found dicts: {}".format(filenames)) - profile.setSpellCheckLanguages(filenames) + log.config.debug("Found dicts: {}".format(filenames)) + self._profile.setSpellCheckLanguages(filenames) def _update_settings(option): """Update global settings when qwebsettings changed.""" global_settings.update_setting(option) - if option in ['scrolling.bar', 'content.user_stylesheets']: - _init_stylesheet(default_profile) - _init_stylesheet(private_profile) - _update_stylesheet() - elif option in ['content.headers.user_agent', - 'content.headers.accept_language']: - _set_http_headers(default_profile) - _set_http_headers(private_profile) + if option in ['content.headers.user_agent', + 'content.headers.accept_language']: + default_profile.setter.set_http_headers() + private_profile.setter.set_http_headers() elif option == 'content.cache.size': - _set_http_cache_size(default_profile) - _set_http_cache_size(private_profile) + default_profile.setter.set_http_cache_size() + private_profile.setter.set_http_cache_size() elif (option == 'content.cookies.store' and # https://bugreports.qt.io/browse/QTBUG-58650 qtutils.version_check('5.9', compiled=False)): - _set_persistent_cookie_policy(default_profile) + default_profile.setter.set_persistent_cookie_policy() # We're not touching the private profile's cookie policy. elif option == 'spellcheck.languages': - _set_dictionary_language(default_profile) - _set_dictionary_language(private_profile, warn=False) - - -def _init_profile(profile): - """Init the given profile.""" - _init_stylesheet(profile) - _set_http_headers(profile) - _set_http_cache_size(profile) - profile.settings().setAttribute( - QWebEngineSettings.FullScreenSupportEnabled, True) - if qtutils.version_check('5.8'): - profile.setSpellCheckEnabled(True) - _set_dictionary_language(profile) + default_profile.setter.set_dictionary_language() + private_profile.setter.set_dictionary_language(warn=False) def _init_profiles(): @@ -303,53 +258,18 @@ def _init_profiles(): global default_profile, private_profile default_profile = QWebEngineProfile.defaultProfile() + default_profile.setter = ProfileSetter(default_profile) default_profile.setCachePath( os.path.join(standarddir.cache(), 'webengine')) default_profile.setPersistentStoragePath( os.path.join(standarddir.data(), 'webengine')) - _init_profile(default_profile) - _set_persistent_cookie_policy(default_profile) + default_profile.setter.init_profile() + default_profile.setter.set_persistent_cookie_policy() private_profile = QWebEngineProfile() + private_profile.setter = ProfileSetter(private_profile) assert private_profile.isOffTheRecord() - _init_profile(private_profile) - - -def inject_userscripts(): - """Register user JavaScript files with the global profiles.""" - # The Greasemonkey metadata block support in QtWebEngine only starts at - # Qt 5.8. With 5.7.1, we need to inject the scripts ourselves in response - # to urlChanged. - if not qtutils.version_check('5.8'): - return - - # Since we are inserting scripts into profile.scripts they won't - # just get replaced by new gm scripts like if we were injecting them - # ourselves so we need to remove all gm scripts, while not removing - # any other stuff that might have been added. Like the one for - # stylesheets. - greasemonkey = objreg.get('greasemonkey') - for profile in [default_profile, private_profile]: - scripts = profile.scripts() - for script in scripts.toList(): - if script.name().startswith("GM-"): - log.greasemonkey.debug('Removing script: {}' - .format(script.name())) - removed = scripts.remove(script) - assert removed, script.name() - - # Then add the new scripts. - for script in greasemonkey.all_scripts(): - # @run-at (and @include/@exclude/@match) is parsed by - # QWebEngineScript. - new_script = QWebEngineScript() - new_script.setWorldId(QWebEngineScript.MainWorld) - new_script.setSourceCode(script.code()) - new_script.setName("GM-{}".format(script.name)) - new_script.setRunsOnSubFrames(script.runs_on_sub_frames) - log.greasemonkey.debug('adding script: {}' - .format(new_script.name())) - scripts.insert(new_script) + private_profile.setter.init_profile() def init(args): diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index f1857327b..6fd2c20b2 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -33,7 +33,7 @@ from PyQt5.QtNetwork import QAuthenticator from PyQt5.QtWidgets import QApplication from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript -from qutebrowser.config import configdata +from qutebrowser.config import configdata, config from qutebrowser.browser import browsertab, mouse, shared from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, interceptor, webenginequtescheme, @@ -73,10 +73,6 @@ def init(): download_manager.install(webenginesettings.private_profile) objreg.register('webengine-download-manager', download_manager) - greasemonkey = objreg.get('greasemonkey') - greasemonkey.scripts_reloaded.connect(webenginesettings.inject_userscripts) - webenginesettings.inject_userscripts() - # Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs. _JS_WORLD_MAP = { @@ -234,7 +230,14 @@ class WebEngineCaret(browsertab.AbstractCaret): self._tab.run_js_async( javascript.assemble('caret', 'setPlatform', sys.platform)) - self._js_call('setInitialCursor') + self._js_call('setInitialCursor', self._selection_cb) + + def _selection_cb(self, enabled): + """Emit selection_toggled based on setInitialCursor.""" + if enabled is None: + log.webview.debug("Ignoring selection status None") + return + self.selection_toggled.emit(enabled) @pyqtSlot(usertypes.KeyMode) def _on_mode_left(self, mode): @@ -301,7 +304,7 @@ class WebEngineCaret(browsertab.AbstractCaret): self._js_call('moveToEndOfDocument') def toggle_selection(self): - self._js_call('toggleSelection') + self._js_call('toggleSelection', self.selection_toggled.emit) def drop_selection(self): self._js_call('dropSelection') @@ -356,9 +359,8 @@ class WebEngineCaret(browsertab.AbstractCaret): self._tab.run_js_async(js_code, lambda jsret: self._follow_selected_cb(jsret, tab)) - def _js_call(self, command): - self._tab.run_js_async( - javascript.assemble('caret', command)) + def _js_call(self, command, callback=None): + self._tab.run_js_async(javascript.assemble('caret', command), callback) class WebEngineScroller(browsertab.AbstractScroller): @@ -379,7 +381,7 @@ class WebEngineScroller(browsertab.AbstractScroller): def _repeated_key_press(self, key, count=1, modifier=Qt.NoModifier): """Send count fake key presses to this scroller's WebEngineTab.""" - for _ in range(min(count, 5000)): + for _ in range(min(count, 1000)): self._tab.key_press(key, modifier) @pyqtSlot(QPointF) @@ -432,6 +434,11 @@ class WebEngineScroller(browsertab.AbstractScroller): js_code = javascript.assemble('window', 'scroll', point.x(), point.y()) self._tab.run_js_async(js_code) + def to_anchor(self, name): + url = self._tab.url() + url.setFragment(name) + self._tab.openurl(url) + def delta(self, x=0, y=0): self._tab.run_js_async(javascript.assemble('window', 'scrollBy', x, y)) @@ -506,6 +513,9 @@ class WebEngineHistory(browsertab.AbstractHistory): return qtutils.deserialize(data, self._history) def load_items(self, items): + if items: + self._tab.predicted_navigation.emit(items[-1].url) + stream, _data, cur_data = tabhistory.serialize(items) qtutils.deserialize_stream(stream, self._history) @@ -627,30 +637,122 @@ class WebEngineTab(browsertab.AbstractTab): self._set_widget(widget) self._connect_signals() self.backend = usertypes.Backend.QtWebEngine - self._init_js() self._child_event_filter = None self._saved_zoom = None self._reload_url = None + config.instance.changed.connect(self._on_config_changed) + self._init_js() + + @pyqtSlot(str) + def _on_config_changed(self, option): + if option in ['scrolling.bar', 'content.user_stylesheets']: + self._init_stylesheet() + self._update_stylesheet() + + def _update_stylesheet(self): + """Update the custom stylesheet in existing tabs.""" + css = shared.get_user_stylesheet() + code = javascript.assemble('stylesheet', 'set_css', css) + self.run_js_async(code) + + def _inject_early_js(self, name, js_code, *, + world=QWebEngineScript.ApplicationWorld, + subframes=False): + """Inject the given script to run early on a page load. + + This runs the script both on DocumentCreation and DocumentReady as on + some internal pages, DocumentCreation will not work. + + That is a WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66011 + """ + scripts = self._widget.page().scripts() + for injection in ['creation', 'ready']: + injection_points = { + 'creation': QWebEngineScript.DocumentCreation, + 'ready': QWebEngineScript.DocumentReady, + } + script = QWebEngineScript() + script.setInjectionPoint(injection_points[injection]) + script.setSourceCode(js_code) + script.setWorldId(world) + script.setRunsOnSubFrames(subframes) + script.setName('_qute_{}_{}'.format(name, injection)) + scripts.insert(script) + + def _remove_early_js(self, name): + """Remove an early QWebEngineScript.""" + scripts = self._widget.page().scripts() + for injection in ['creation', 'ready']: + full_name = '_qute_{}_{}'.format(name, injection) + script = scripts.findScript(full_name) + if not script.isNull(): + scripts.remove(script) def _init_js(self): - js_code = '\n'.join([ - '"use strict";', - 'window._qutebrowser = window._qutebrowser || {};', + """Initialize global qutebrowser JavaScript.""" + js_code = javascript.wrap_global( + 'scripts', utils.read_file('javascript/scroll.js'), utils.read_file('javascript/webelem.js'), utils.read_file('javascript/caret.js'), - ]) - script = QWebEngineScript() - # We can't use DocumentCreation here as WORKAROUND for - # https://bugreports.qt.io/browse/QTBUG-66011 - script.setInjectionPoint(QWebEngineScript.DocumentReady) - script.setSourceCode(js_code) + ) + # FIXME:qtwebengine what about subframes=True? + self._inject_early_js('js', js_code, subframes=True) + self._init_stylesheet() - page = self._widget.page() - script.setWorldId(QWebEngineScript.ApplicationWorld) + greasemonkey = objreg.get('greasemonkey') + greasemonkey.scripts_reloaded.connect(self._inject_userscripts) + self._inject_userscripts() - # FIXME:qtwebengine what about runsOnSubFrames? - page.scripts().insert(script) + def _init_stylesheet(self): + """Initialize custom stylesheets. + + Partially inspired by QupZilla: + https://github.com/QupZilla/qupzilla/blob/v2.0/src/lib/app/mainapplication.cpp#L1063-L1101 + """ + self._remove_early_js('stylesheet') + css = shared.get_user_stylesheet() + js_code = javascript.wrap_global( + 'stylesheet', + utils.read_file('javascript/stylesheet.js'), + javascript.assemble('stylesheet', 'set_css', css), + ) + self._inject_early_js('stylesheet', js_code, subframes=True) + + def _inject_userscripts(self): + """Register user JavaScript files with the global profiles.""" + # The Greasemonkey metadata block support in QtWebEngine only starts at + # Qt 5.8. With 5.7.1, we need to inject the scripts ourselves in + # response to urlChanged. + if not qtutils.version_check('5.8'): + return + + # Since we are inserting scripts into profile.scripts they won't + # just get replaced by new gm scripts like if we were injecting them + # ourselves so we need to remove all gm scripts, while not removing + # any other stuff that might have been added. Like the one for + # stylesheets. + greasemonkey = objreg.get('greasemonkey') + scripts = self._widget.page().scripts() + for script in scripts.toList(): + if script.name().startswith("GM-"): + log.greasemonkey.debug('Removing script: {}' + .format(script.name())) + removed = scripts.remove(script) + assert removed, script.name() + + # Then add the new scripts. + for script in greasemonkey.all_scripts(): + # @run-at (and @include/@exclude/@match) is parsed by + # QWebEngineScript. + new_script = QWebEngineScript() + new_script.setWorldId(QWebEngineScript.MainWorld) + new_script.setSourceCode(script.code()) + new_script.setName("GM-{}".format(script.name)) + new_script.setRunsOnSubFrames(script.runs_on_sub_frames) + log.greasemonkey.debug('adding script: {}' + .format(new_script.name())) + scripts.insert(new_script) def _install_event_filter(self): self._widget.focusProxy().installEventFilter(self._mouse_event_filter) @@ -669,9 +771,15 @@ class WebEngineTab(browsertab.AbstractTab): self.zoom.set_factor(self._saved_zoom) self._saved_zoom = None - def openurl(self, url): + def openurl(self, url, *, predict=True): + """Open the given URL in this tab. + + Arguments: + url: The QUrl to open. + predict: If set to False, predicted_navigation is not emitted. + """ self._saved_zoom = self.zoom.factor() - self._openurl_prepare(url) + self._openurl_prepare(url, predict=predict) self._widget.load(url) def url(self, requested=False): @@ -706,7 +814,6 @@ class WebEngineTab(browsertab.AbstractTab): self._widget.shutdown() def reload(self, *, force=False): - self.predicted_navigation.emit(self.url()) if force: action = QWebEnginePage.ReloadAndBypassCache else: @@ -915,10 +1022,10 @@ class WebEngineTab(browsertab.AbstractTab): if ok and self._reload_url is not None: # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-66656 log.config.debug( - "Reloading {} because of config change".format( + "Loading {} again because of config change".format( self._reload_url.toDisplayString())) QTimer.singleShot(100, lambda url=self._reload_url: - self.openurl(url)) + self.openurl(url, predict=False)) self._reload_url = None if not qtutils.version_check('5.10', compiled=False): @@ -931,6 +1038,7 @@ class WebEngineTab(browsertab.AbstractTab): @pyqtSlot(QUrl) def _on_predicted_navigation(self, url): """If we know we're going to visit an URL soon, change the settings.""" + super()._on_predicted_navigation(url) self.settings.update_for_url(url) @pyqtSlot(usertypes.NavigationRequest) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 422645e61..9d5305f10 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -196,9 +196,10 @@ class WebKitCaret(browsertab.AbstractCaret): if mode != usertypes.KeyMode.caret: return + self.selection_enabled = self._widget.hasSelection() + self.selection_toggled.emit(self.selection_enabled) settings = self._widget.settings() settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True) - self.selection_enabled = self._widget.hasSelection() if self._widget.isVisible(): # Sometimes the caret isn't immediately visible, but unfocusing @@ -363,9 +364,7 @@ class WebKitCaret(browsertab.AbstractCaret): def toggle_selection(self): self.selection_enabled = not self.selection_enabled - mainwindow = objreg.get('main-window', scope='window', - window=self._tab.win_id) - mainwindow.status.set_mode_active(usertypes.KeyMode.caret, True) + self.selection_toggled.emit(self.selection_enabled) def drop_selection(self): self._widget.triggerPageAction(QWebPage.MoveToNextChar) @@ -427,6 +426,9 @@ class WebKitScroller(browsertab.AbstractScroller): def to_point(self, point): self._widget.page().mainFrame().setScrollPosition(point) + def to_anchor(self, name): + self._widget.page().mainFrame().scrollToAnchor(name) + def delta(self, x=0, y=0): qtutils.check_overflow(x, 'int') qtutils.check_overflow(y, 'int') @@ -537,6 +539,9 @@ class WebKitHistory(browsertab.AbstractHistory): return qtutils.deserialize(data, self._history) def load_items(self, items): + if items: + self._tab.predicted_navigation.emit(items[-1].url) + stream, _data, user_data = tabhistory.serialize(items) qtutils.deserialize_stream(stream, self._history) for i, data in enumerate(user_data): @@ -668,8 +673,8 @@ class WebKitTab(browsertab.AbstractTab): settings = widget.settings() settings.setAttribute(QWebSettings.PrivateBrowsingEnabled, True) - def openurl(self, url): - self._openurl_prepare(url) + def openurl(self, url, *, predict=True): + self._openurl_prepare(url, predict=predict) self._widget.openurl(url) def url(self, requested=False): @@ -701,7 +706,6 @@ class WebKitTab(browsertab.AbstractTab): self._widget.shutdown() def reload(self, *, force=False): - self.predicted_navigation.emit(self.url()) if force: action = QWebPage.ReloadAndBypassCache else: diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 7b0a5caf5..6bbc27109 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -239,7 +239,6 @@ class BrowserPage(QWebPage): printdiag.setAttribute(Qt.WA_DeleteOnClose) printdiag.open(lambda: frame.print(printdiag.printer())) - @pyqtSlot('QNetworkRequest') def on_download_requested(self, request): """Called when the user wants to download a link. diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index 049d89295..35beb24de 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -110,18 +110,18 @@ def _buffer(skip_win_id=None): model = completionmodel.CompletionModel(column_widths=(6, 40, 54)) for win_id in objreg.window_registry: - if skip_win_id and win_id == skip_win_id: + if skip_win_id is not None and win_id == skip_win_id: continue tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) if tabbed_browser.shutting_down: continue tabs = [] - for idx in range(tabbed_browser.count()): - tab = tabbed_browser.widget(idx) + for idx in range(tabbed_browser.widget.count()): + tab = tabbed_browser.widget.widget(idx) tabs.append(("{}/{}".format(win_id, idx + 1), tab.url().toDisplayString(), - tabbed_browser.page_title(idx))) + tabbed_browser.widget.page_title(idx))) cat = listcategory.ListCategory("{}".format(win_id), tabs, delete_func=delete_buffer) model.add_category(cat) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index a6a2d5317..7034d030c 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -425,11 +425,7 @@ content.host_blocking.enabled: content.host_blocking.lists: default: - - "https://www.malwaredomainlist.com/hostslist/hosts.txt" - - "http://someonewhocares.org/hosts/hosts" - - "http://winhelp2002.mvps.org/hosts.zip" - - "http://malwaredomains.lehigh.edu/files/justdomains.zip" - - "https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&mimetype=plaintext" + - "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" type: name: List valtype: Url @@ -1252,9 +1248,14 @@ tabs.favicons.scale: `tabs.padding`. tabs.favicons.show: - default: true - type: Bool - desc: Show favicons in the tab bar. + default: always + type: + name: String + valid_values: + - always: Always show favicons. + - never: Always hide favicons. + - pinned: Show favicons only on pinned tabs. + desc: When to show favicons in the tab bar. tabs.last_close: default: ignore @@ -1325,7 +1326,10 @@ tabs.show: tabs.show_switching_delay: default: 800 - type: Int + type: + name: Int + minval: 0 + maxval: maxint desc: "Duration (in milliseconds) to show the tab bar before hiding it when tabs.show is set to 'switching'." @@ -1406,6 +1410,19 @@ tabs.width: desc: "Width (in pixels or as percentage of the window) of the tab bar if it's vertical." +tabs.min_width: + default: -1 + type: + name: Int + minval: -1 + maxval: maxint + desc: >- + Minimum width (in pixels) of tabs (-1 for the default minimum size behavior). + + This setting only applies when tabs are horizontal. + + This setting does not apply to pinned tabs, unless `tabs.pinned.shrink` is False. + tabs.width.indicator: renamed: tabs.indicator.width @@ -1469,6 +1486,11 @@ url.incdec_segments: desc: URL segments where `:navigate increment/decrement` will search for a number. +url.open_base_url: + type: Bool + default: false + desc: Open base URL of the searchengine if a searchengine shortcut is invoked without parameters. + url.searchengines: default: DEFAULT: https://duckduckgo.com/?q={} @@ -1513,10 +1535,15 @@ url.yank_ignored_parameters: ## window window.hide_wayland_decoration: + renamed: window.hide_decoration + +window.hide_decoration: type: Bool default: false - restart: true - desc: Hide the window decoration when using wayland. + desc: | + Hide the window decoration. + + This setting requires a restart on Wayland. window.title_format: type: diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index fdb1583e0..986ca3f56 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -268,6 +268,15 @@ class YamlConfig(QObject): del settings['bindings.default'] self._mark_changed() + # Option to show favicons only for pinned tabs changed the type of + # tabs.favicons.show from Bool to String + name = 'tabs.favicons.show' + if name in settings: + for scope, val in settings[name].items(): + if isinstance(val, bool): + settings[name][scope] = 'always' if val else 'never' + self._mark_changed() + return settings def _validate(self, settings): diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index c7c9362b0..1f9f0f4b6 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -26,7 +26,8 @@ from PyQt5.QtWidgets import QMessageBox from qutebrowser.config import (config, configdata, configfiles, configtypes, configexc, configcommands) -from qutebrowser.utils import objreg, usertypes, log, standarddir, message +from qutebrowser.utils import (objreg, usertypes, log, standarddir, message, + qtutils) from qutebrowser.misc import msgbox, objects @@ -89,7 +90,7 @@ def _init_envvars(): if config.val.qt.force_platform is not None: os.environ['QT_QPA_PLATFORM'] = config.val.qt.force_platform - if config.val.window.hide_wayland_decoration: + if config.val.window.hide_decoration: os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1' if config.val.qt.highdpi: @@ -161,4 +162,12 @@ def qt_args(namespace): argv += ['--' + name, value] argv += ['--' + arg for arg in config.val.qt.args] + + if (objects.backend == usertypes.Backend.QtWebEngine and + not qtutils.version_check('5.11', compiled=False)): + # WORKAROUND equivalent to + # https://codereview.qt-project.org/#/c/217932/ + # Needed for Qt < 5.9.5 and < 5.10.1 + argv.append('--disable-shared-workers') + return argv diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 14855bf03..ecf1efe86 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -451,7 +451,7 @@ class List(BaseType): def from_obj(self, value): if value is None: return [] - return value + return [self.valtype.from_obj(v) for v in value] def to_py(self, value): self._basic_py_validation(value, list) @@ -506,6 +506,16 @@ class ListOrValue(BaseType): self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs) self.valtype = valtype + def _val_and_type(self, value): + """Get the value and type to use for to_str/to_doc/from_str.""" + if isinstance(value, list): + if len(value) == 1: + return value[0], self.valtype + else: + return value, self.listtype + else: + return value, self.valtype + def get_name(self): return self.listtype.get_name() + ', or ' + self.valtype.get_name() @@ -533,25 +543,15 @@ class ListOrValue(BaseType): if value is None: return '' - if isinstance(value, list): - if len(value) == 1: - return self.valtype.to_str(value[0]) - else: - return self.listtype.to_str(value) - else: - return self.valtype.to_str(value) + val, typ = self._val_and_type(value) + return typ.to_str(val) def to_doc(self, value, indent=0): if value is None: return 'empty' - if isinstance(value, list): - if len(value) == 1: - return self.valtype.to_doc(value[0], indent) - else: - return self.listtype.to_doc(value, indent) - else: - return self.valtype.to_doc(value, indent) + val, typ = self._val_and_type(value) + return typ.to_doc(val) class FlagList(List): @@ -1199,7 +1199,9 @@ class Dict(BaseType): def from_obj(self, value): if value is None: return {} - return value + + return {self.keytype.from_obj(key): self.valtype.from_obj(val) + for key, val in value.items()} def _fill_fixed_keys(self, value): """Fill missing fixed keys with a None-value.""" @@ -1623,9 +1625,7 @@ class TimestampTemplate(BaseType): """An strftime-like template for timestamps. - See - https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior - for reference. + See https://sqlite.org/lang_datefunc.html for reference. """ def to_py(self, value): @@ -1648,6 +1648,10 @@ class Key(BaseType): """A name of a key.""" + def from_obj(self, value): + """Make sure key sequences are always normalized.""" + return str(keyutils.KeySequence.parse(value)) + def to_py(self, value): self._basic_py_validation(value, str) if not value: diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index c069f7d56..cfb53e658 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -22,7 +22,7 @@ from PyQt5.QtGui import QFont from qutebrowser.config import config, configutils -from qutebrowser.utils import log, usertypes, urlmatch +from qutebrowser.utils import log, usertypes, urlmatch, qtutils from qutebrowser.misc import objects UNSET = object() @@ -141,6 +141,7 @@ class AbstractSettings: Return: A set of settings which actually changed. """ + qtutils.ensure_valid(url) changed_settings = set() for values in config.instance: if not values.opt.supports_pattern: diff --git a/qutebrowser/javascript/.eslintignore b/qutebrowser/javascript/.eslintignore index 036a72cfe..65143f360 100644 --- a/qutebrowser/javascript/.eslintignore +++ b/qutebrowser/javascript/.eslintignore @@ -2,3 +2,4 @@ pac_utils.js # Actually a jinja template so eslint chokes on the {{}} syntax. greasemonkey_wrapper.js +global_wrapper.js diff --git a/qutebrowser/javascript/caret.js b/qutebrowser/javascript/caret.js index 5088c3e2f..7e862befc 100644 --- a/qutebrowser/javascript/caret.js +++ b/qutebrowser/javascript/caret.js @@ -324,9 +324,8 @@ window._qutebrowser.caret = (function() { const color = axs.color.parseColor(style.backgroundColor); if (color && (style.opacity < 1 && - (color.alpha *= style.opacity), - color.alpha !== 0 && - (el.push(color), color.alpha === 1))) { + (color.alpha *= style.opacity), color.alpha !== 0 && + (el.push(color), color.alpha === 1))) { iter = !0; break; } @@ -1270,13 +1269,14 @@ window._qutebrowser.caret = (function() { funcs.setInitialCursor = () => { if (!CaretBrowsing.initiated) { CaretBrowsing.setInitialCursor(); - return; + return CaretBrowsing.selectionEnabled; } if (window.getSelection().toString().length === 0) { positionCaret(); } CaretBrowsing.toggle(); + return CaretBrowsing.selectionEnabled; }; funcs.setPlatform = (platform) => { @@ -1362,6 +1362,7 @@ window._qutebrowser.caret = (function() { funcs.toggleSelection = () => { CaretBrowsing.selectionEnabled = !CaretBrowsing.selectionEnabled; + return CaretBrowsing.selectionEnabled; }; return funcs; diff --git a/qutebrowser/javascript/global_wrapper.js b/qutebrowser/javascript/global_wrapper.js new file mode 100644 index 000000000..a302bd5d1 --- /dev/null +++ b/qutebrowser/javascript/global_wrapper.js @@ -0,0 +1,12 @@ +(function() { + "use strict"; + if (!("_qutebrowser" in window)) { + window._qutebrowser = {"initialized": {}}; + } + + if (window._qutebrowser.initialized["{{name}}"]) { + return; + } + {{code}} + window._qutebrowser.initialized["{{name}}"] = true; +})(); diff --git a/qutebrowser/javascript/greasemonkey_wrapper.js b/qutebrowser/javascript/greasemonkey_wrapper.js index 71266755a..0731e93ac 100644 --- a/qutebrowser/javascript/greasemonkey_wrapper.js +++ b/qutebrowser/javascript/greasemonkey_wrapper.js @@ -1,5 +1,5 @@ (function() { - const _qute_script_id = "__gm_" + {{ scriptName | tojson }}; + const _qute_script_id = "__gm_{{ scriptName }}"; function GM_log(text) { console.log(text); @@ -7,7 +7,7 @@ const GM_info = { 'script': {{ scriptInfo }}, - 'scriptMetaStr': {{ scriptMeta | tojson }}, + 'scriptMetaStr': "{{ scriptMeta }}", 'scriptWillUpdate': false, 'version': "0.0.1", // so scripts don't expect exportFunction @@ -100,11 +100,8 @@ const head = document.getElementsByTagName("head")[0]; if (head === undefined) { - document.onreadystatechange = function() { - if (document.readyState === "interactive") { - document.getElementsByTagName("head")[0].appendChild(oStyle); - } - }; + // no head yet, stick it whereever + document.documentElement.appendChild(oStyle); } else { head.appendChild(oStyle); } diff --git a/qutebrowser/javascript/webelem.js b/qutebrowser/javascript/webelem.js index eb6ce2790..f7ab0f636 100644 --- a/qutebrowser/javascript/webelem.js +++ b/qutebrowser/javascript/webelem.js @@ -74,9 +74,8 @@ window._qutebrowser.webelem = (function() { try { return elem.selectionStart; } catch (err) { - if (err instanceof (frame - ? frame.DOMException - : DOMException) && + if ((err instanceof DOMException || + (frame && err instanceof frame.DOMException)) && err.name === "InvalidStateError") { // nothing to do, caret_position is already null } else { diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 1582a5485..f0f2c6f28 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -108,11 +108,43 @@ class BaseKeyParser(QObject): assert not isinstance(seq, str), seq match = sequence.matches(seq) if match == QKeySequence.ExactMatch: - return (match, cmd) + return match, cmd elif match == QKeySequence.PartialMatch: result = QKeySequence.PartialMatch - return (result, None) + return result, None + + def _match_without_modifiers(self, sequence): + """Try to match a key with optional modifiers stripped.""" + self._debug_log("Trying match without modifiers") + sequence = sequence.strip_modifiers() + match, binding = self._match_key(sequence) + return match, binding, sequence + + def _match_key_mapping(self, sequence): + """Try to match a key in bindings.key_mappings.""" + self._debug_log("Trying match with key_mappings") + mapped = sequence.with_mappings(config.val.bindings.key_mappings) + if sequence != mapped: + self._debug_log("Mapped {} -> {}".format( + sequence, mapped)) + match, binding = self._match_key(mapped) + sequence = mapped + return match, binding, sequence + return QKeySequence.NoMatch, None, sequence + + def _match_count(self, sequence, dry_run): + """Try to match a key as count.""" + txt = str(sequence[-1]) # To account for sequences changed above. + if (txt.isdigit() and self._supports_count and + not (not self._count and txt == '0')): + self._debug_log("Trying match as count") + assert len(txt) == 1, txt + if not dry_run: + self._count += txt + self.keystring_updated.emit(self._count + str(self._sequence)) + return True + return False def handle(self, e, *, dry_run=False): """Handle a new keypress. @@ -146,28 +178,15 @@ class BaseKeyParser(QObject): self.clear_keystring() return QKeySequence.NoMatch - # First, try a straightforward match match, binding = self._match_key(sequence) - - # If that doesn't match, try a key_mapping if match == QKeySequence.NoMatch: - mapped = sequence.with_mappings(config.val.bindings.key_mappings) - if sequence != mapped: - self._debug_log("Mapped {} -> {}".format( - sequence, mapped)) - match, binding = self._match_key(mapped) - sequence = mapped - - # If that doesn't match either, try treating it as count. - if (match == QKeySequence.NoMatch and - txt.isdigit() and - self._supports_count and - not (not self._count and txt == '0')): - assert len(txt) == 1, txt - if not dry_run: - self._count += txt - self.keystring_updated.emit(self._count + str(self._sequence)) - return QKeySequence.ExactMatch + match, binding, sequence = self._match_without_modifiers(sequence) + if match == QKeySequence.NoMatch: + match, binding, sequence = self._match_key_mapping(sequence) + if match == QKeySequence.NoMatch: + was_count = self._match_count(sequence, dry_run) + if was_count: + return QKeySequence.ExactMatch if dry_run: return match diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index a56efeab8..f0cf9c22a 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -58,7 +58,8 @@ def is_special(key, modifiers): _assert_plain_key(key) _assert_plain_modifier(modifiers) return not (_is_printable(key) and - modifiers in [Qt.ShiftModifier, Qt.NoModifier]) + modifiers in [Qt.ShiftModifier, Qt.NoModifier, + Qt.KeypadModifier]) def is_modifier_key(key): @@ -303,7 +304,8 @@ class KeyInfo: key_string = key_string.lower() # "special" binding - assert is_special(self.key, self.modifiers) + assert (is_special(self.key, self.modifiers) or + self.modifiers == Qt.KeypadModifier) modifier_string = _modifiers_to_string(modifiers) return '<{}{}>'.format(modifier_string, key_string) @@ -505,11 +507,29 @@ class KeySequence: not ev.text().isupper()): modifiers = Qt.KeyboardModifiers() + # On macOS, swap Ctrl and Meta back + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-51293 + if utils.is_mac: + if modifiers & Qt.ControlModifier and modifiers & Qt.MetaModifier: + pass + elif modifiers & Qt.ControlModifier: + modifiers &= ~Qt.ControlModifier + modifiers |= Qt.MetaModifier + elif modifiers & Qt.MetaModifier: + modifiers &= ~Qt.MetaModifier + modifiers |= Qt.ControlModifier + keys = list(self._iter_keys()) keys.append(key | int(modifiers)) return self.__class__(*keys) + def strip_modifiers(self): + """Strip optional modifiers from keys.""" + modifiers = Qt.KeypadModifier + keys = [key & ~modifiers for key in self._iter_keys()] + return self.__class__(*keys) + def with_mappings(self, mappings): """Get a new KeySequence with the given mappings applied.""" keys = [] diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 05482a1d5..0f66d6797 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -184,7 +184,8 @@ class MainWindow(QWidget): private = bool(private) self._private = private self.tabbed_browser = tabbedbrowser.TabbedBrowser(win_id=self.win_id, - private=private) + private=private, + parent=self) objreg.register('tabbed-browser', self.tabbed_browser, scope='window', window=self.win_id) self._init_command_dispatcher() @@ -230,6 +231,7 @@ class MainWindow(QWidget): config.instance.changed.connect(self._on_config_changed) objreg.get("app").new_window.emit(self) + self._set_decoration(config.val.window.hide_decoration) def _init_geometry(self, geometry): """Initialize the window geometry or load it from disk.""" @@ -327,7 +329,7 @@ class MainWindow(QWidget): self.tabbed_browser) objreg.register('command-dispatcher', dispatcher, scope='window', window=self.win_id) - self.tabbed_browser.destroyed.connect( + self.tabbed_browser.widget.destroyed.connect( functools.partial(objreg.delete, 'command-dispatcher', scope='window', window=self.win_id)) @@ -344,13 +346,15 @@ class MainWindow(QWidget): elif option == 'statusbar.position': self._add_widgets() self._update_overlay_geometries() + elif option == 'window.hide_decoration': + self._set_decoration(config.val.window.hide_decoration) def _add_widgets(self): """Add or readd all widgets to the VBox.""" - self._vbox.removeWidget(self.tabbed_browser) + self._vbox.removeWidget(self.tabbed_browser.widget) self._vbox.removeWidget(self._downloadview) self._vbox.removeWidget(self.status) - widgets = [self.tabbed_browser] + widgets = [self.tabbed_browser.widget] downloads_position = config.val.downloads.position if downloads_position == 'top': @@ -469,7 +473,7 @@ class MainWindow(QWidget): self.tabbed_browser.cur_scroll_perc_changed.connect( status.percentage.set_perc) - self.tabbed_browser.tab_index_changed.connect( + self.tabbed_browser.widget.tab_index_changed.connect( status.tabindex.on_tab_index_changed) self.tabbed_browser.cur_url_changed.connect(status.url.set_url) @@ -479,6 +483,10 @@ class MainWindow(QWidget): self.tabbed_browser.cur_link_hovered.connect(status.url.set_hover_url) self.tabbed_browser.cur_load_status_changed.connect( status.url.on_load_status_changed) + + self.tabbed_browser.cur_caret_selection_toggled.connect( + status.on_caret_selection_toggled) + self.tabbed_browser.cur_fullscreen_requested.connect( self._on_fullscreen_requested) self.tabbed_browser.cur_fullscreen_requested.connect(status.maybe_hide) @@ -489,6 +497,16 @@ class MainWindow(QWidget): completion_obj.on_clear_completion_selection) cmd.hide_completion.connect(completion_obj.hide) + def _set_decoration(self, hidden): + """Set the visibility of the window decoration via Qt.""" + window_flags = Qt.Window + refresh_window = self.isVisible() + if hidden: + window_flags |= Qt.CustomizeWindowHint | Qt.NoDropShadowWindowHint + self.setWindowFlags(window_flags) + if refresh_window: + self.show() + @pyqtSlot(bool) def _on_fullscreen_requested(self, on): if not config.val.content.windowed_fullscreen: @@ -517,7 +535,7 @@ class MainWindow(QWidget): super().resizeEvent(e) self._update_overlay_geometries() self._downloadview.updateGeometry() - self.tabbed_browser.tabBar().refresh() + self.tabbed_browser.widget.tabBar().refresh() def showEvent(self, e): """Extend showEvent to register us as the last-visible-main-window. @@ -546,7 +564,7 @@ class MainWindow(QWidget): if crashsignal.is_crashing: e.accept() return - tab_count = self.tabbed_browser.count() + tab_count = self.tabbed_browser.widget.count() download_model = objreg.get('download-model', scope='window', window=self.win_id) download_count = download_model.running_downloads() diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 90415b261..f7af28440 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -596,6 +596,8 @@ class FilenamePrompt(_BasePrompt): if config.val.prompt.filebrowser: self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + self._to_complete = '' + @pyqtSlot(str) def _set_fileview_root(self, path, *, tabbed=False): """Set the root path for the file display.""" @@ -604,6 +606,9 @@ class FilenamePrompt(_BasePrompt): separators += os.altsep dirname = os.path.dirname(path) + basename = os.path.basename(path) + if not tabbed: + self._to_complete = '' try: if not path: @@ -617,6 +622,7 @@ class FilenamePrompt(_BasePrompt): elif os.path.isdir(dirname) and not tabbed: # Input like /foo/ba -> show /foo contents path = dirname + self._to_complete = basename else: return except OSError: @@ -634,7 +640,11 @@ class FilenamePrompt(_BasePrompt): index: The QModelIndex of the selected element. clicked: Whether the element was clicked. """ - path = os.path.normpath(self._file_model.filePath(index)) + if index == QModelIndex(): + path = os.path.join(self._file_model.rootPath(), self._to_complete) + else: + path = os.path.normpath(self._file_model.filePath(index)) + if clicked: path += os.sep else: @@ -696,6 +706,7 @@ class FilenamePrompt(_BasePrompt): assert last_index.isValid() idx = selmodel.currentIndex() + if not idx.isValid(): # No item selected yet idx = last_index if which == 'prev' else first_index @@ -709,10 +720,24 @@ class FilenamePrompt(_BasePrompt): if not idx.isValid(): idx = last_index if which == 'prev' else first_index + idx = self._do_completion(idx, which) + selmodel.setCurrentIndex( idx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) self._insert_path(idx, clicked=False) + def _do_completion(self, idx, which): + filename = self._file_model.fileName(idx) + while not filename.startswith(self._to_complete) and idx.isValid(): + if which == 'prev': + idx = self._file_view.indexAbove(idx) + else: + assert which == 'next', which + idx = self._file_view.indexBelow(idx) + filename = self._file_model.fileName(idx) + + return idx + def _allowed_commands(self): return [('prompt-accept', 'Accept'), ('leave-mode', 'Abort')] diff --git a/qutebrowser/mainwindow/statusbar/backforward.py b/qutebrowser/mainwindow/statusbar/backforward.py index 8ea60ee75..5e244cf8c 100644 --- a/qutebrowser/mainwindow/statusbar/backforward.py +++ b/qutebrowser/mainwindow/statusbar/backforward.py @@ -32,7 +32,7 @@ class Backforward(textbase.TextBase): def on_tab_cur_url_changed(self, tabs): """Called on URL changes.""" - tab = tabs.currentWidget() + tab = tabs.widget.currentWidget() if tab is None: # pragma: no cover self.setText('') self.hide() diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 8057bfdb8..c3ef53b1b 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -268,7 +268,7 @@ class StatusBar(QWidget): """Get the currently displayed tab.""" window = objreg.get('tabbed-browser', scope='window', window=self._win_id) - return window.currentWidget() + return window.widget.currentWidget() def set_mode_active(self, mode, val): """Setter for self.{insert,command,caret}_active. @@ -289,17 +289,9 @@ class StatusBar(QWidget): log.statusbar.debug("Setting prompt flag to {}".format(val)) self._color_flags.prompt = val elif mode == usertypes.KeyMode.caret: - tab = self._current_tab() - log.statusbar.debug("Setting caret flag - val {}, selection " - "{}".format(val, tab.caret.selection_enabled)) - if val: - if tab.caret.selection_enabled: - self._set_mode_text("{} selection".format(mode.name)) - self._color_flags.caret = ColorFlags.CaretMode.selection - else: - self._set_mode_text(mode.name) - self._color_flags.caret = ColorFlags.CaretMode.on - else: + if not val: + # Turning on is handled in on_current_caret_selection_toggled + log.statusbar.debug("Setting caret mode off") self._color_flags.caret = ColorFlags.CaretMode.off config.set_register_stylesheet(self, update=False) @@ -377,6 +369,18 @@ class StatusBar(QWidget): self.maybe_hide() assert tab.private == self._color_flags.private + @pyqtSlot(bool) + def on_caret_selection_toggled(self, selection): + """Update the statusbar when entering/leaving caret selection mode.""" + log.statusbar.debug("Setting caret selection {}".format(selection)) + if selection: + self._set_mode_text("caret selection") + self._color_flags.caret = ColorFlags.CaretMode.selection + else: + self._set_mode_text("caret") + self._color_flags.caret = ColorFlags.CaretMode.on + config.set_register_stylesheet(self, update=False) + def resizeEvent(self, e): """Extend resizeEvent of QWidget to emit a resized signal afterwards. diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index f59018c43..ce36f0038 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -22,7 +22,7 @@ import functools import attr -from PyQt5.QtWidgets import QSizePolicy +from PyQt5.QtWidgets import QSizePolicy, QWidget from PyQt5.QtCore import pyqtSignal, pyqtSlot, QTimer, QUrl from PyQt5.QtGui import QIcon @@ -50,7 +50,7 @@ class TabDeletedError(Exception): """Exception raised when _tab_index is called for a deleted tab.""" -class TabbedBrowser(tabwidget.TabWidget): +class TabbedBrowser(QWidget): """A TabWidget with QWebViews inside. @@ -104,23 +104,25 @@ class TabbedBrowser(tabwidget.TabWidget): cur_scroll_perc_changed = pyqtSignal(int, int) cur_load_status_changed = pyqtSignal(str) cur_fullscreen_requested = pyqtSignal(bool) + cur_caret_selection_toggled = pyqtSignal(bool) close_window = pyqtSignal() resized = pyqtSignal('QRect') current_tab_changed = pyqtSignal(browsertab.AbstractTab) new_tab = pyqtSignal(browsertab.AbstractTab, int) def __init__(self, *, win_id, private, parent=None): - super().__init__(win_id, parent) + super().__init__(parent) + self.widget = tabwidget.TabWidget(win_id, parent=self) self._win_id = win_id self._tab_insert_idx_left = 0 self._tab_insert_idx_right = -1 self.shutting_down = False - self.tabCloseRequested.connect(self.on_tab_close_requested) - self.new_tab_requested.connect(self.tabopen) - self.currentChanged.connect(self.on_current_changed) + self.widget.tabCloseRequested.connect(self.on_tab_close_requested) + self.widget.new_tab_requested.connect(self.tabopen) + self.widget.currentChanged.connect(self.on_current_changed) self.cur_load_started.connect(self.on_cur_load_started) - self.cur_fullscreen_requested.connect(self.tabBar().maybe_hide) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.cur_fullscreen_requested.connect(self.widget.tabBar().maybe_hide) + self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._undo_stack = [] self._filter = signalfilter.SignalFilter(win_id, self) self._now_focused = None @@ -128,12 +130,12 @@ class TabbedBrowser(tabwidget.TabWidget): self.search_options = {} self._local_marks = {} self._global_marks = {} - self.default_window_icon = self.window().windowIcon() + self.default_window_icon = self.widget.window().windowIcon() self.private = private config.instance.changed.connect(self._on_config_changed) def __repr__(self): - return utils.get_repr(self, count=self.count()) + return utils.get_repr(self, count=self.widget.count()) @pyqtSlot(str) def _on_config_changed(self, option): @@ -142,7 +144,7 @@ class TabbedBrowser(tabwidget.TabWidget): elif option == 'window.title_format': self._update_window_title() elif option in ['tabs.title.format', 'tabs.title.format_pinned']: - self._update_tab_titles() + self.widget.update_tab_titles() def _tab_index(self, tab): """Get the index of a given tab. @@ -150,7 +152,7 @@ class TabbedBrowser(tabwidget.TabWidget): Raises TabDeletedError if the tab doesn't exist anymore. """ try: - idx = self.indexOf(tab) + idx = self.widget.indexOf(tab) except RuntimeError as e: log.webview.debug("Got invalid tab ({})!".format(e)) raise TabDeletedError(e) @@ -166,8 +168,8 @@ class TabbedBrowser(tabwidget.TabWidget): iterating over the list. """ widgets = [] - for i in range(self.count()): - widget = self.widget(i) + for i in range(self.widget.count()): + widget = self.widget.widget(i) if widget is None: log.webview.debug("Got None-widget in tabbedbrowser!") else: @@ -186,16 +188,16 @@ class TabbedBrowser(tabwidget.TabWidget): if field is not None and ('{' + field + '}') not in title_format: return - idx = self.currentIndex() + idx = self.widget.currentIndex() if idx == -1: # (e.g. last tab removed) log.webview.debug("Not updating window title because index is -1") return - fields = self.get_tab_fields(idx) + fields = self.widget.get_tab_fields(idx) fields['id'] = self._win_id title = title_format.format(**fields) - self.window().setWindowTitle(title) + self.widget.window().setWindowTitle(title) def _connect_tab_signals(self, tab): """Set up the needed signals for tab.""" @@ -216,6 +218,8 @@ class TabbedBrowser(tabwidget.TabWidget): self._filter.create(self.cur_load_status_changed, tab)) tab.fullscreen_requested.connect( self._filter.create(self.cur_fullscreen_requested, tab)) + tab.caret.selection_toggled.connect( + self._filter.create(self.cur_caret_selection_toggled, tab)) # misc tab.scroller.perc_changed.connect(self.on_scroll_pos_changed) tab.url_changed.connect( @@ -247,8 +251,8 @@ class TabbedBrowser(tabwidget.TabWidget): Return: The current URL as QUrl. """ - idx = self.currentIndex() - return super().tab_url(idx) + idx = self.widget.currentIndex() + return self.widget.tab_url(idx) def shutdown(self): """Try to shut down all tabs cleanly.""" @@ -284,7 +288,7 @@ class TabbedBrowser(tabwidget.TabWidget): new_undo: Whether the undo entry should be a new item in the stack. """ last_close = config.val.tabs.last_close - count = self.count() + count = self.widget.count() if last_close == 'ignore' and count == 1: return @@ -311,7 +315,7 @@ class TabbedBrowser(tabwidget.TabWidget): new_undo: Whether the undo entry should be a new item in the stack. crashed: Whether we're closing a tab with crashed renderer process. """ - idx = self.indexOf(tab) + idx = self.widget.indexOf(tab) if idx == -1: if crashed: return @@ -349,7 +353,7 @@ class TabbedBrowser(tabwidget.TabWidget): self._undo_stack[-1].append(entry) tab.shutdown() - self.removeTab(idx) + self.widget.removeTab(idx) if not crashed: # WORKAROUND for a segfault when we delete the crashed tab. # see https://bugreports.qt.io/browse/QTBUG-58698 @@ -362,14 +366,14 @@ class TabbedBrowser(tabwidget.TabWidget): last_close = config.val.tabs.last_close use_current_tab = False if last_close in ['blank', 'startpage', 'default-page']: - only_one_tab_open = self.count() == 1 - no_history = len(self.widget(0).history) == 1 + only_one_tab_open = self.widget.count() == 1 + no_history = len(self.widget.widget(0).history) == 1 urls = { 'blank': QUrl('about:blank'), 'startpage': config.val.url.start_pages[0], 'default-page': config.val.url.default_page, } - first_tab_url = self.widget(0).url() + first_tab_url = self.widget.widget(0).url() last_close_urlstr = urls[last_close].toString().rstrip('/') first_tab_urlstr = first_tab_url.toString().rstrip('/') last_close_url_used = first_tab_urlstr == last_close_urlstr @@ -378,13 +382,13 @@ class TabbedBrowser(tabwidget.TabWidget): for entry in reversed(self._undo_stack.pop()): if use_current_tab: - newtab = self.widget(0) + newtab = self.widget.widget(0) use_current_tab = False else: newtab = self.tabopen(background=False, idx=entry.index) newtab.history.deserialize(entry.history) - self.set_tab_pinned(newtab, entry.pinned) + self.widget.set_tab_pinned(newtab, entry.pinned) @pyqtSlot('QUrl', bool) def openurl(self, url, newtab): @@ -395,15 +399,15 @@ class TabbedBrowser(tabwidget.TabWidget): newtab: True to open URL in a new tab, False otherwise. """ qtutils.ensure_valid(url) - if newtab or self.currentWidget() is None: + if newtab or self.widget.currentWidget() is None: self.tabopen(url, background=False) else: - self.currentWidget().openurl(url) + self.widget.currentWidget().openurl(url) @pyqtSlot(int) def on_tab_close_requested(self, idx): """Close a tab via an index.""" - tab = self.widget(idx) + tab = self.widget.widget(idx) if tab is None: log.webview.debug("Got invalid tab {} for index {}!".format( tab, idx)) @@ -454,7 +458,7 @@ class TabbedBrowser(tabwidget.TabWidget): "related {}, idx {}".format( url, background, related, idx)) - if (config.val.tabs.tabs_are_windows and self.count() > 0 and + if (config.val.tabs.tabs_are_windows and self.widget.count() > 0 and not ignore_tabs_are_windows): window = mainwindow.MainWindow(private=self.private) window.show() @@ -464,12 +468,12 @@ class TabbedBrowser(tabwidget.TabWidget): related=related) tab = browsertab.create(win_id=self._win_id, private=self.private, - parent=self) + parent=self.widget) self._connect_tab_signals(tab) if idx is None: idx = self._get_new_tab_idx(related) - self.insertTab(idx, tab, "") + self.widget.insertTab(idx, tab, "") if url is not None: tab.openurl(url) @@ -480,10 +484,11 @@ class TabbedBrowser(tabwidget.TabWidget): # Make sure the background tab has the correct initial size. # With a foreground tab, it's going to be resized correctly by the # layout anyways. - tab.resize(self.currentWidget().size()) - self.tab_index_changed.emit(self.currentIndex(), self.count()) + tab.resize(self.widget.currentWidget().size()) + self.widget.tab_index_changed.emit(self.widget.currentIndex(), + self.widget.count()) else: - self.setCurrentWidget(tab) + self.widget.setCurrentWidget(tab) tab.show() self.new_tab.emit(tab, idx) @@ -526,15 +531,8 @@ class TabbedBrowser(tabwidget.TabWidget): def _update_favicons(self): """Update favicons when config was changed.""" - for i, tab in enumerate(self.widgets()): - if config.val.tabs.favicons.show: - self.setTabIcon(i, tab.icon()) - if config.val.tabs.tabs_are_windows: - self.window().setWindowIcon(tab.icon()) - else: - self.setTabIcon(i, QIcon()) - if config.val.tabs.tabs_are_windows: - self.window().setWindowIcon(self.default_window_icon) + for tab in self.widgets(): + self.widget.update_tab_favicon(tab) @pyqtSlot() def on_load_started(self, tab): @@ -548,14 +546,14 @@ class TabbedBrowser(tabwidget.TabWidget): except TabDeletedError: # We can get signals for tabs we already deleted... return - self._update_tab_title(idx) + self.widget.update_tab_title(idx) if tab.data.keep_icon: tab.data.keep_icon = False else: if (config.val.tabs.tabs_are_windows and - config.val.tabs.favicons.show): - self.window().setWindowIcon(self.default_window_icon) - if idx == self.currentIndex(): + tab.data.should_show_icon()): + self.widget.window().setWindowIcon(self.default_window_icon) + if idx == self.widget.currentIndex(): self._update_window_title() @pyqtSlot() @@ -586,8 +584,8 @@ class TabbedBrowser(tabwidget.TabWidget): return log.webview.debug("Changing title for idx {} to '{}'".format( idx, text)) - self.set_page_title(idx, text) - if idx == self.currentIndex(): + self.widget.set_page_title(idx, text) + if idx == self.widget.currentIndex(): self._update_window_title() @pyqtSlot(browsertab.AbstractTab, QUrl) @@ -604,8 +602,8 @@ class TabbedBrowser(tabwidget.TabWidget): # We can get signals for tabs we already deleted... return - if not self.page_title(idx): - self.set_page_title(idx, url.toDisplayString()) + if not self.widget.page_title(idx): + self.widget.set_page_title(idx, url.toDisplayString()) @pyqtSlot(browsertab.AbstractTab, QIcon) def on_icon_changed(self, tab, icon): @@ -617,23 +615,23 @@ class TabbedBrowser(tabwidget.TabWidget): tab: The WebView where the title was changed. icon: The new icon """ - if not config.val.tabs.favicons.show: + if not tab.data.should_show_icon(): return try: idx = self._tab_index(tab) except TabDeletedError: # We can get signals for tabs we already deleted... return - self.setTabIcon(idx, icon) + self.widget.setTabIcon(idx, icon) if config.val.tabs.tabs_are_windows: - self.window().setWindowIcon(icon) + self.widget.window().setWindowIcon(icon) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): """Give focus to current tab if command mode was left.""" if mode in [usertypes.KeyMode.command, usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: - widget = self.currentWidget() + widget = self.widget.currentWidget() log.modes.debug("Left status-input mode, focusing {!r}".format( widget)) if widget is None: @@ -649,7 +647,7 @@ class TabbedBrowser(tabwidget.TabWidget): if idx == -1 or self.shutting_down: # closing the last tab (before quitting) or shutting down return - tab = self.widget(idx) + tab = self.widget.widget(idx) if tab is None: log.webview.debug("on_current_changed got called with invalid " "index {}".format(idx)) @@ -677,8 +675,8 @@ class TabbedBrowser(tabwidget.TabWidget): self._now_focused = tab self.current_tab_changed.emit(tab) QTimer.singleShot(0, self._update_window_title) - self._tab_insert_idx_left = self.currentIndex() - self._tab_insert_idx_right = self.currentIndex() + 1 + self._tab_insert_idx_left = self.widget.currentIndex() + self._tab_insert_idx_right = self.widget.currentIndex() + 1 @pyqtSlot() def on_cmd_return_pressed(self): @@ -696,9 +694,9 @@ class TabbedBrowser(tabwidget.TabWidget): stop = config.val.colors.tabs.indicator.stop system = config.val.colors.tabs.indicator.system color = utils.interpolate_color(start, stop, perc, system) - self.set_tab_indicator_color(idx, color) - self._update_tab_title(idx) - if idx == self.currentIndex(): + self.widget.set_tab_indicator_color(idx, color) + self.widget.update_tab_title(idx) + if idx == self.widget.currentIndex(): self._update_window_title() def on_load_finished(self, tab, ok): @@ -715,23 +713,23 @@ class TabbedBrowser(tabwidget.TabWidget): color = utils.interpolate_color(start, stop, 100, system) else: color = config.val.colors.tabs.indicator.error - self.set_tab_indicator_color(idx, color) - self._update_tab_title(idx) - if idx == self.currentIndex(): + self.widget.set_tab_indicator_color(idx, color) + self.widget.update_tab_title(idx) + if idx == self.widget.currentIndex(): self._update_window_title() tab.handle_auto_insert_mode(ok) @pyqtSlot() def on_scroll_pos_changed(self): """Update tab and window title when scroll position changed.""" - idx = self.currentIndex() + idx = self.widget.currentIndex() if idx == -1: # (e.g. last tab removed) log.webview.debug("Not updating scroll position because index is " "-1") return self._update_window_title('scroll_pos') - self._update_tab_title(idx, 'scroll_pos') + self.widget.update_tab_title(idx, 'scroll_pos') def _on_renderer_process_terminated(self, tab, status, code): """Show an error when a renderer process terminated.""" @@ -764,7 +762,7 @@ class TabbedBrowser(tabwidget.TabWidget): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-58698 message.error(msg) self._remove_tab(tab, crashed=True) - if self.count() == 0: + if self.widget.count() == 0: self.tabopen(QUrl('about:blank')) def resizeEvent(self, e): @@ -801,7 +799,7 @@ class TabbedBrowser(tabwidget.TabWidget): if key != "'": message.error("Failed to set mark: url invalid") return - point = self.currentWidget().scroller.pos_px() + point = self.widget.currentWidget().scroller.pos_px() if key.isupper(): self._global_marks[key] = point, url @@ -822,7 +820,7 @@ class TabbedBrowser(tabwidget.TabWidget): except qtutils.QtValueError: urlkey = None - tab = self.currentWidget() + tab = self.widget.currentWidget() if key.isupper(): if key in self._global_marks: diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 965e5b219..6a6eac901 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -60,7 +60,7 @@ class TabWidget(QTabWidget): self.setTabBar(bar) bar.tabCloseRequested.connect(self.tabCloseRequested) bar.tabMoved.connect(functools.partial( - QTimer.singleShot, 0, self._update_tab_titles)) + QTimer.singleShot, 0, self.update_tab_titles)) bar.currentChanged.connect(self._on_current_changed) bar.new_tab_requested.connect(self._on_new_tab_requested) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -108,7 +108,8 @@ class TabWidget(QTabWidget): bar.set_tab_data(idx, 'pinned', pinned) tab.data.pinned = pinned - self._update_tab_title(idx) + self.update_tab_favicon(tab) + self.update_tab_title(idx) def tab_indicator_color(self, idx): """Get the tab indicator color for the given index.""" @@ -117,13 +118,13 @@ class TabWidget(QTabWidget): def set_page_title(self, idx, title): """Set the tab title user data.""" self.tabBar().set_tab_data(idx, 'page-title', title) - self._update_tab_title(idx) + self.update_tab_title(idx) def page_title(self, idx): """Get the tab title user data.""" return self.tabBar().page_title(idx) - def _update_tab_title(self, idx, field=None): + def update_tab_title(self, idx, field=None): """Update the tab text for the given tab. Args: @@ -148,9 +149,13 @@ class TabWidget(QTabWidget): title = '' if fmt is None else fmt.format(**fields) tabbar = self.tabBar() + # Only change the tab title if it changes, setting the tab title causes + # a size recalculation which is slow. if tabbar.tabText(idx) != title: tabbar.setTabText(idx, title) - tabbar.setTabToolTip(idx, title) + + # always show only plain title in tooltips + tabbar.setTabToolTip(idx, fields['title']) def get_tab_fields(self, idx): """Get the tab field data.""" @@ -197,20 +202,20 @@ class TabWidget(QTabWidget): fields['scroll_pos'] = scroll_pos return fields - def _update_tab_titles(self): + def update_tab_titles(self): """Update all texts.""" for idx in range(self.count()): - self._update_tab_title(idx) + self.update_tab_title(idx) def tabInserted(self, idx): """Update titles when a tab was inserted.""" super().tabInserted(idx) - self._update_tab_titles() + self.update_tab_titles() def tabRemoved(self, idx): """Update titles when a tab was removed.""" super().tabRemoved(idx) - self._update_tab_titles() + self.update_tab_titles() def addTab(self, page, icon_or_text, text_or_empty=None): """Override addTab to use our own text setting logic. @@ -296,6 +301,19 @@ class TabWidget(QTabWidget): qtutils.ensure_valid(url) return url + def update_tab_favicon(self, tab: QWidget): + """Update favicon of the given tab.""" + idx = self.indexOf(tab) + + if tab.data.should_show_icon(): + self.setTabIcon(idx, tab.icon()) + if config.val.tabs.tabs_are_windows: + self.window().setWindowIcon(tab.icon()) + else: + self.setTabIcon(idx, QIcon()) + if config.val.tabs.tabs_are_windows: + self.window().setWindowIcon(self.window().windowIcon()) + class TabBar(QTabBar): @@ -358,7 +376,9 @@ class TabBar(QTabBar): # Clear _minimum_tab_size_hint_helper cache when appropriate if option in ["tabs.indicator.padding", "tabs.padding", - "tabs.indicator.width"]: + "tabs.indicator.width", + "tabs.min_width", + "tabs.pinned.shrink"]: self._minimum_tab_size_hint_helper.cache_clear() def _on_show_switching_delay_changed(self): @@ -477,7 +497,8 @@ class TabBar(QTabBar): Args: index: The index of the tab to get a size hint for. ellipsis: Whether to use ellipsis to calculate width - instead of the tab's text. + instead of the tab's text. + Forced to False for pinned tabs. Return: A QSize of the smallest tab size we can make. """ @@ -489,14 +510,19 @@ class TabBar(QTabBar): else: icon_width = min(icon.actualSize(self.iconSize()).width(), self.iconSize().width()) + icon_padding + + pinned = self._tab_pinned(index) + if not self.vertical and pinned and config.val.tabs.pinned.shrink: + # Never consider ellipsis an option for horizontal pinned tabs + ellipsis = False return self._minimum_tab_size_hint_helper(self.tabText(index), - icon_width, - ellipsis) + icon_width, ellipsis, + pinned) @functools.lru_cache(maxsize=2**9) def _minimum_tab_size_hint_helper(self, tab_text: str, icon_width: int, - ellipsis: bool) -> QSize: + ellipsis: bool, pinned: bool) -> QSize: """Helper function to cache tab results. Config values accessed in here should be added to _on_config_changed to @@ -521,6 +547,10 @@ class TabBar(QTabBar): height = self.fontMetrics().height() + padding_v width = (text_width + icon_width + padding_h + indicator_width) + min_width = config.val.tabs.min_width + if (not self.vertical and min_width > 0 and + not pinned or not config.val.tabs.pinned.shrink): + width = max(min_width, width) return QSize(width, height) def _pinned_statistics(self) -> (int, int): @@ -550,6 +580,12 @@ class TabBar(QTabBar): Return: A QSize. """ + if self.count() == 0: + # This happens on startup on macOS. + # We return it directly rather than setting `size' because we don't + # want to ensure it's valid in this special case. + return QSize() + minimum_size = self.minimumTabSizeHint(index) height = minimum_size.height() if self.vertical: @@ -562,11 +598,6 @@ class TabBar(QTabBar): else: width = int(confwidth) size = QSize(max(minimum_size.width(), width), height) - elif self.count() == 0: - # This happens on startup on macOS. - # We return it directly rather than setting `size' because we don't - # want to ensure it's valid in this special case. - return QSize() else: if config.val.tabs.pinned.shrink: pinned = self._tab_pinned(index) @@ -889,7 +920,7 @@ class TabBarStyle(QCommonStyle): # reserve space for favicon when tab bar is vertical (issue #1968) position = config.val.tabs.position if (position in [QTabWidget.East, QTabWidget.West] and - config.val.tabs.favicons.show): + config.val.tabs.favicons.show != 'never'): tab_icon_size = icon_size else: actual_size = opt.icon.actualSize(icon_size, icon_mode, icon_state) diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 154660001..473f67c3e 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -42,6 +42,7 @@ class ExternalEditor(QObject): _proc: The GUIProcess of the editor. _watcher: A QFileSystemWatcher to watch the edited file for changes. Only set if watch=True. + _content: The last-saved text of the editor. Signals: file_updated: The text in the edited file was updated. @@ -112,19 +113,7 @@ class ExternalEditor(QObject): if self._filename is not None: raise ValueError("Already editing a file!") try: - # Close while the external process is running, as otherwise systems - # with exclusive write access (e.g. Windows) may fail to update - # the file from the external editor, see - # https://github.com/qutebrowser/qutebrowser/issues/1767 - with tempfile.NamedTemporaryFile( - # pylint: disable=bad-continuation - mode='w', prefix='qutebrowser-editor-', - encoding=config.val.editor.encoding, - delete=False) as fobj: - # pylint: enable=bad-continuation - if text: - fobj.write(text) - self._filename = fobj.name + self._filename = self._create_tempfile(text, 'qutebrowser-editor-') except OSError as e: message.error("Failed to create initial file: {}".format(e)) return @@ -134,6 +123,32 @@ class ExternalEditor(QObject): line, column = self._calc_line_and_column(text, caret_position) self._start_editor(line=line, column=column) + def backup(self): + """Create a backup if the content has changed from the original.""" + if not self._content: + return + try: + fname = self._create_tempfile(self._content, + 'qutebrowser-editor-backup-') + message.info('Editor backup at {}'.format(fname)) + except OSError as e: + message.error('Failed to create editor backup: {}'.format(e)) + + def _create_tempfile(self, text, prefix): + # Close while the external process is running, as otherwise systems + # with exclusive write access (e.g. Windows) may fail to update + # the file from the external editor, see + # https://github.com/qutebrowser/qutebrowser/issues/1767 + with tempfile.NamedTemporaryFile( + # pylint: disable=bad-continuation + mode='w', prefix=prefix, + encoding=config.val.editor.encoding, + delete=False) as fobj: + # pylint: enable=bad-continuation + if text: + fobj.write(text) + return fobj.name + @pyqtSlot(str) def _on_file_changed(self, path): try: diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index 11446aa40..e1de9a6cc 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -130,7 +130,7 @@ class KeyHintView(QLabel): ).format( html.escape(prefix), suffix_color, - html.escape(str(seq[len(prefix):])), + html.escape(str(seq)[len(prefix):]), html.escape(cmd) ) text = '{}
'.format(text) diff --git a/qutebrowser/misc/sessions.py b/qutebrowser/misc/sessions.py index a8a652dbb..dddf48b05 100644 --- a/qutebrowser/misc/sessions.py +++ b/qutebrowser/misc/sessions.py @@ -246,7 +246,7 @@ class SessionManager(QObject): if tabbed_browser.private: win_data['private'] = True for i, tab in enumerate(tabbed_browser.widgets()): - active = i == tabbed_browser.currentIndex() + active = i == tabbed_browser.widget.currentIndex() win_data['tabs'].append(self._save_tab(tab, active)) data['windows'].append(win_data) return data @@ -427,11 +427,12 @@ class SessionManager(QObject): if tab.get('active', False): tab_to_focus = i if new_tab.data.pinned: - tabbed_browser.set_tab_pinned(new_tab, new_tab.data.pinned) + tabbed_browser.widget.set_tab_pinned(new_tab, + new_tab.data.pinned) if tab_to_focus is not None: - tabbed_browser.setCurrentIndex(tab_to_focus) + tabbed_browser.widget.setCurrentIndex(tab_to_focus) if win.get('active', False): - QTimer.singleShot(0, tabbed_browser.activateWindow) + QTimer.singleShot(0, tabbed_browser.widget.activateWindow) if data['windows']: self.did_load = True diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index d2743d56e..4b55eb04e 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -185,7 +185,7 @@ def debug_cache_stats(): tabbed_browser = objreg.get('tabbed-browser', scope='window', window='last-focused') # pylint: disable=protected-access - tab_bar = tabbed_browser.tabBar() + tab_bar = tabbed_browser.widget.tabBar() tabbed_browser_info = tab_bar._minimum_tab_size_hint_helper.cache_info() # pylint: enable=protected-access diff --git a/qutebrowser/utils/debug.py b/qutebrowser/utils/debug.py index 06bdd2909..2868d8390 100644 --- a/qutebrowser/utils/debug.py +++ b/qutebrowser/utils/debug.py @@ -87,10 +87,11 @@ def log_signals(obj): return ret obj.__init__ = new_init - return obj else: connect_log_slot(obj) + return obj + def qenum_key(base, value, add_base=False, klass=None): """Convert a Qt Enum value to its key as a string. diff --git a/qutebrowser/utils/javascript.py b/qutebrowser/utils/javascript.py index 335b1b983..93df8e70f 100644 --- a/qutebrowser/utils/javascript.py +++ b/qutebrowser/utils/javascript.py @@ -20,6 +20,9 @@ """Utilities related to javascript interaction.""" +from qutebrowser.utils import jinja + + def string_escape(text): """Escape values special to javascript in strings. @@ -70,3 +73,9 @@ def assemble(module, function, *args): parts = ['window', '_qutebrowser', module, function] code = '"use strict";\n{}({});'.format('.'.join(parts), js_args) return code + + +def wrap_global(name, *sources): + """Wrap a script using window._qutebrowser.""" + template = jinja.js_environment.get_template('global_wrapper.js') + return template.render(code='\n'.join(sources), name=name) diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index 8d44a9eb5..17fc34b92 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -171,7 +171,7 @@ def _get_tab_registry(win_id, tab_id): if tab_id == 'current': tabbed_browser = get('tabbed-browser', scope='window', window=win_id) - tab = tabbed_browser.currentWidget() + tab = tabbed_browser.widget.currentWidget() if tab is None: raise RegistryUnavailableError('window') tab_id = tab.tab_id diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 2ed466dd1..0c77a5d0f 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -102,6 +102,12 @@ def _get_search_url(txt): engine = 'DEFAULT' template = config.val.url.searchengines[engine] url = qurl_from_user_input(template.format(urllib.parse.quote(term))) + + if config.val.url.open_base_url and term in config.val.url.searchengines: + url = qurl_from_user_input(config.val.url.searchengines[term]) + url.setPath(None) + url.setFragment(None) + url.setQuery(None) qtutils.ensure_valid(url) return url diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 016adaa03..86973a3de 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -317,8 +317,10 @@ def _chromium_version(): Qt 5.8: Chromium 53 Qt 5.9: Chromium 56 Qt 5.10: Chromium 61 - Qt 5.11: Chromium 63 - Qt 5.12: Chromium 65 (?) + Qt 5.11: Chromium 65 + Qt 5.12: Chromium 69 (?) + + Also see https://www.chromium.org/developers/calendar """ if QWebEngineProfile is None: # This should never happen diff --git a/scripts/dev/build_release.py b/scripts/dev/build_release.py index 666e0432e..84e897eee 100755 --- a/scripts/dev/build_release.py +++ b/scripts/dev/build_release.py @@ -361,7 +361,7 @@ def github_upload(artifacts, tag): repo = gh.repository('qutebrowser', 'qutebrowser') release = None # to satisfy pylint - for release in repo.iter_releases(): + for release in repo.releases(): if release.tag_name == tag: break else: @@ -401,14 +401,6 @@ def main(): run_asciidoc2html(args) if os.name == 'nt': - if sys.maxsize > 2**32: - # WORKAROUND - print("Due to a python/Windows bug, this script needs to be run ") - print("with a 32bit Python.") - print() - print("See http://bugs.python.org/issue24493 and ") - print("https://github.com/pypa/virtualenv/issues/774") - sys.exit(1) artifacts = build_windows() elif sys.platform == 'darwin': artifacts = build_mac() diff --git a/tests/end2end/data/hints/issue3711.html b/tests/end2end/data/hints/issue3711.html new file mode 100644 index 000000000..6abceccc2 --- /dev/null +++ b/tests/end2end/data/hints/issue3711.html @@ -0,0 +1,13 @@ + + + + Issue 3711 + + + + + + diff --git a/tests/end2end/data/hints/issue3711_frame.html b/tests/end2end/data/hints/issue3711_frame.html new file mode 100644 index 000000000..37c5e5b71 --- /dev/null +++ b/tests/end2end/data/hints/issue3711_frame.html @@ -0,0 +1,11 @@ + + + + + + Issue 3771 Parent Frame + + + + + diff --git a/tests/end2end/data/javascript/window_open.html b/tests/end2end/data/javascript/window_open.html index f842380d6..f5eafa0bc 100644 --- a/tests/end2end/data/javascript/window_open.html +++ b/tests/end2end/data/javascript/window_open.html @@ -3,10 +3,10 @@ @@ -33,7 +35,7 @@ - + diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature index 391e88749..33535856c 100644 --- a/tests/end2end/features/editor.feature +++ b/tests/end2end/features/editor.feature @@ -128,6 +128,7 @@ Feature: Opening external editors And I run :tab-close And I kill the waiting editor Then the error "Edited element vanished" should be shown + And the message "Editor backup at *" should be shown # Could not get signals working on Windows @posix diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index a2ac468d0..a1c4d0bde 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -249,6 +249,11 @@ Feature: Using hints And I hint with args "all current" and follow a Then no crash should happen + Scenario: No error when hinting ranged input in frames + When I open data/hints/issue3711_frame.html + And I hint with args "all current" and follow a + Then no crash should happen + ### hints.auto_follow.timeout @not_mac @flaky diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index 637f4696c..208aeab88 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -8,6 +8,7 @@ Feature: Javascript stuff When I open data/javascript/consolelog.html Then the javascript message "console.log works!" should be logged + @flaky Scenario: Opening/Closing a window via JS When I open data/javascript/window_open.html And I run :tab-only @@ -15,7 +16,10 @@ Feature: Javascript stuff And I wait for "Changing title for idx 1 to 'about:blank'" in the log And I run :tab-focus 1 And I run :click-element id close-normal + And I wait for "[*] window closed" in the log Then "Focus object changed: *" should be logged + And the following tabs should be open: + - data/javascript/window_open.html (active) @qtwebkit_skip Scenario: Opening/closing a modal window via JS @@ -25,8 +29,11 @@ Feature: Javascript stuff And I wait for "Changing title for idx 1 to 'about:blank'" in the log And I run :tab-focus 1 And I run :click-element id close-normal + And I wait for "[*] window closed" in the log Then "Focus object changed: *" should be logged And "Web*Dialog requested, but we don't support that!" should be logged + And the following tabs should be open: + - data/javascript/window_open.html (active) # https://github.com/qutebrowser/qutebrowser/issues/906 @@ -39,6 +46,7 @@ Feature: Javascript stuff And I wait for "Changing title for idx 2 to 'about:blank'" in the log And I run :tab-focus 2 And I run :click-element id close-twice + And I wait for "[*] window closed" in the log Then "Requested to close * which does not exist!" should be logged @qtwebkit_skip @flaky @@ -51,6 +59,7 @@ Feature: Javascript stuff And I run :buffer window_open.html And I run :click-element id close-twice And I wait for "Focus object changed: *" in the log + And I wait for "[*] window closed" in the log Then no crash should happen @flaky @@ -174,3 +183,15 @@ Feature: Javascript stuff When I set content.javascript.enabled to false And I open 500 without waiting Then "Showing error page for* 500" should be logged + + Scenario: Using JS after window.open + When I open data/hello.txt + And I set content.javascript.can_open_tabs_automatically to true + And I run :jseval window.open('about:blank') + And I open data/hello.txt + And I run :tab-only + And I open data/hints/html/simple.html + And I run :hint all + And I wait for "hints: a" in the log + And I run :leave-mode + Then "There was an error while getting hint elements" should not be logged diff --git a/tests/end2end/features/navigate.feature b/tests/end2end/features/navigate.feature index 307a6c53a..b40b4d9cc 100644 --- a/tests/end2end/features/navigate.feature +++ b/tests/end2end/features/navigate.feature @@ -26,7 +26,7 @@ Feature: Using :navigate # prev/next Scenario: Navigating to previous page - When I open data/navigate + When I open data/navigate in a new tab And I run :navigate prev Then data/navigate/prev.html should be loaded diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 7a36b60cb..1841ef9c9 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -336,13 +336,13 @@ Feature: Tab management When I set tabs.wrap to false And I open data/numbers/1.txt And I run :tab-prev - Then the error "First tab" should be shown + Then "First tab" should be logged Scenario: :tab-next with last tab without wrap When I set tabs.wrap to false And I open data/numbers/1.txt And I run :tab-next - Then the error "Last tab" should be shown + Then "Last tab" should be logged Scenario: :tab-prev on first tab with wrap When I set tabs.wrap to true diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 0b5f683cc..e30dc03fa 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -101,6 +101,9 @@ def is_ignored_lowlevel_message(message): ' Error: No such file or directory', # Qt 5.7.1 'qt.network.ssl: QSslSocket: cannot call unresolved function *', + # Qt 5.11 + # DevTools listening on ws://127.0.0.1:37945/devtools/browser/... + 'DevTools listening on *', ] return any(testutils.pattern_match(pattern=pattern, value=message) for pattern in ignored_messages) @@ -169,7 +172,7 @@ def is_ignored_chromium_message(line): # /tmp/pytest-of-florian/pytest-32/test_webengine_download_suffix0/ # downloads/download.bin: Operation not supported ('Could not set extended attribute user.xdg.* on file *: ' - 'Operation not supported'), + 'Operation not supported*'), # [5947:5947:0605/192837.856931:ERROR:render_process_impl.cc(112)] # WebFrame LEAKED 1 TIMES 'WebFrame LEAKED 1 TIMES', @@ -192,6 +195,15 @@ def is_ignored_chromium_message(line): # [2734:2746:1107/131154.072032:ERROR:nss_ocsp.cc(591)] No # URLRequestContext for NSS HTTP handler. host: ocsp.digicert.com 'No URLRequestContext for NSS HTTP handler. host: *', + + # https://bugreports.qt.io/browse/QTBUG-66661 + # [23359:23359:0319/115812.168578:WARNING: + # render_frame_host_impl.cc(2744)] OnDidStopLoading was called twice. + 'OnDidStopLoading was called twice.', + + # [30412:30412:0323/074933.387250:ERROR:node_channel.cc(899)] Dropping + # message on closed channel. + 'Dropping message on closed channel.', ] return any(testutils.pattern_match(pattern=pattern, value=message) for pattern in ignored_messages) diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index 3b6d51e26..d6b4b1300 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -379,6 +379,7 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new): @pytest.mark.no_xvfb @pytest.mark.no_ci +@pytest.mark.not_mac def test_force_software_rendering(request, quteproc_new): """Make sure we can force software rendering with -s.""" if not request.config.webengine: diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index f9f02ba8b..0e147b211 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -43,7 +43,8 @@ import helpers.stubs as stubsmod import helpers.utils from qutebrowser.config import (config, configdata, configtypes, configexc, configfiles) -from qutebrowser.utils import objreg, standarddir +from qutebrowser.utils import objreg, standarddir, utils +from qutebrowser.browser import greasemonkey from qutebrowser.browser.webkit import cookies from qutebrowser.misc import savemanager, sql from qutebrowser.keyinput import modeman @@ -143,6 +144,47 @@ def fake_web_tab(stubs, tab_registry, mode_manager, qapp): return stubs.FakeWebTab +@pytest.fixture +def greasemonkey_manager(data_tmpdir): + gm_manager = greasemonkey.GreasemonkeyManager() + objreg.register('greasemonkey', gm_manager) + yield + objreg.delete('greasemonkey') + + +@pytest.fixture +def webkit_tab(qtbot, tab_registry, cookiejar_and_cache, mode_manager, + session_manager_stub, greasemonkey_manager): + webkittab = pytest.importorskip('qutebrowser.browser.webkit.webkittab') + tab = webkittab.WebKitTab(win_id=0, mode_manager=mode_manager, + private=False) + qtbot.add_widget(tab) + return tab + + +@pytest.fixture +def webengine_tab(qtbot, tab_registry, fake_args, mode_manager, + session_manager_stub, greasemonkey_manager, + redirect_webengine_data): + webenginetab = pytest.importorskip( + 'qutebrowser.browser.webengine.webenginetab') + tab = webenginetab.WebEngineTab(win_id=0, mode_manager=mode_manager, + private=False) + qtbot.add_widget(tab) + return tab + + +@pytest.fixture(params=['webkit', 'webengine']) +def web_tab(request): + """A WebKitTab/WebEngineTab.""" + if request.param == 'webkit': + return request.getfixturevalue('webkit_tab') + elif request.param == 'webengine': + return request.getfixturevalue('webengine_tab') + else: + raise utils.Unreachable + + def _generate_cmdline_tests(): """Generate testcases for test_split_binding.""" @attr.s @@ -193,11 +235,15 @@ def configdata_init(): @pytest.fixture -def config_stub(stubs, monkeypatch, configdata_init, config_tmpdir): - """Fixture which provides a fake config object.""" - yaml_config = configfiles.YamlConfig() +def yaml_config_stub(config_tmpdir): + """Fixture which provides a YamlConfig object.""" + return configfiles.YamlConfig() - conf = config.Config(yaml_config=yaml_config) + +@pytest.fixture +def config_stub(stubs, monkeypatch, configdata_init, yaml_config_stub): + """Fixture which provides a fake config object.""" + conf = config.Config(yaml_config=yaml_config_stub) monkeypatch.setattr(config, 'instance', conf) container = config.ConfigContainer(conf) diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index fbe7035e3..545b32fb4 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -27,6 +27,7 @@ import shutil import attr from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject, QUrl +from PyQt5.QtGui import QIcon from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar @@ -266,6 +267,9 @@ class FakeWebTab(browsertab.AbstractTab): def shutdown(self): pass + def icon(self): + return QIcon() + class FakeSignal: @@ -472,37 +476,55 @@ class SessionManagerStub: def list_sessions(self): return self.sessions + def save_autosave(self): + pass + class TabbedBrowserStub(QObject): """Stub for the tabbed-browser object.""" + def __init__(self, parent=None): + super().__init__(parent) + self.widget = TabWidgetStub() + self.shutting_down = False + self.opened_url = None + + def on_tab_close_requested(self, idx): + del self.widget.tabs[idx] + + def widgets(self): + return self.widget.tabs + + def tabopen(self, url): + self.opened_url = url + + def openurl(self, url, *, newtab): + self.opened_url = url + + +class TabWidgetStub(QObject): + + """Stub for the tab-widget object.""" + new_tab = pyqtSignal(browsertab.AbstractTab, int) def __init__(self, parent=None): super().__init__(parent) self.tabs = [] - self.shutting_down = False self._qtabbar = QTabBar() self.index_of = None self.current_index = None - self.opened_url = None def count(self): return len(self.tabs) - def widgets(self): - return self.tabs - def widget(self, i): return self.tabs[i] def page_title(self, i): return self.tabs[i].title() - def on_tab_close_requested(self, idx): - del self.tabs[idx] - def tabBar(self): return self._qtabbar @@ -526,12 +548,6 @@ class TabbedBrowserStub(QObject): return None return self.tabs[idx - 1] - def tabopen(self, url): - self.opened_url = url - - def openurl(self, url, *, newtab): - self.opened_url = url - class ApplicationStub(QObject): diff --git a/tests/unit/browser/test_adblock.py b/tests/unit/browser/test_adblock.py index 5b353efb9..8ab3b930d 100644 --- a/tests/unit/browser/test_adblock.py +++ b/tests/unit/browser/test_adblock.py @@ -120,8 +120,10 @@ def assert_urls(host_blocker, blocked=BLOCKLIST_HOSTS, Ensure URLs in 'blocked' and not in 'whitelisted' are blocked. All other URLs must not be blocked. + + localhost is an example of a special case that shouldn't be blocked. """ - whitelisted = list(whitelisted) + list(host_blocker.WHITELISTED) + whitelisted = list(whitelisted) + ['localhost'] for str_url in urls_to_check: url = QUrl(str_url) host = url.host() @@ -247,6 +249,16 @@ def test_successful_update(config_stub, basedir, download_stub, assert_urls(host_blocker, whitelisted=[]) +def test_parsing_multiple_hosts_on_line(config_stub, basedir, download_stub, + data_tmpdir, tmpdir, win_registry, + caplog): + """Ensure multiple hosts on a line get parsed correctly.""" + host_blocker = adblock.HostBlocker() + bytes_host_line = ' '.join(BLOCKLIST_HOSTS).encode('utf-8') + host_blocker._parse_line(bytes_host_line) + assert_urls(host_blocker, whitelisted=[]) + + def test_failed_dl_update(config_stub, basedir, download_stub, data_tmpdir, tmpdir, win_registry, caplog): """One blocklist fails to download. @@ -341,7 +353,7 @@ def test_blocking_with_whitelist(config_stub, basedir, download_stub, """Ensure hosts in content.host_blocking.whitelist are never blocked.""" # Simulate adblock_update has already been run # by creating a file named blocked-hosts, - # Exclude localhost from it, since localhost is in HostBlocker.WHITELISTED + # Exclude localhost from it as localhost is never blocked via list filtered_blocked_hosts = BLOCKLIST_HOSTS[1:] blocklist = create_blocklist(data_tmpdir, blocked_hosts=filtered_blocked_hosts, diff --git a/tests/unit/browser/test_signalfilter.py b/tests/unit/browser/test_signalfilter.py index 18f52a32a..957b85943 100644 --- a/tests/unit/browser/test_signalfilter.py +++ b/tests/unit/browser/test_signalfilter.py @@ -68,8 +68,8 @@ def objects(): @pytest.mark.parametrize('index_of, emitted', [(0, True), (1, False)]) def test_filtering(objects, tabbed_browser_stubs, index_of, emitted): browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = index_of + browser.widget.current_index = 0 + browser.widget.index_of = index_of objects.signaller.signal.emit('foo') if emitted: assert objects.signaller.filtered_signal_arg == 'foo' @@ -80,8 +80,8 @@ def test_filtering(objects, tabbed_browser_stubs, index_of, emitted): @pytest.mark.parametrize('index_of, verb', [(0, 'emitting'), (1, 'ignoring')]) def test_logging(caplog, objects, tabbed_browser_stubs, index_of, verb): browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = index_of + browser.widget.current_index = 0 + browser.widget.index_of = index_of with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.signal.emit('foo') @@ -94,8 +94,8 @@ def test_logging(caplog, objects, tabbed_browser_stubs, index_of, verb): @pytest.mark.parametrize('index_of', [0, 1]) def test_no_logging(caplog, objects, tabbed_browser_stubs, index_of): browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = index_of + browser.widget.current_index = 0 + browser.widget.index_of = index_of with caplog.at_level(logging.DEBUG, logger='signals'): objects.signaller.link_hovered.emit('foo') @@ -106,7 +106,7 @@ def test_no_logging(caplog, objects, tabbed_browser_stubs, index_of): def test_runtime_error(objects, tabbed_browser_stubs): """Test that there's no crash if indexOf() raises RuntimeError.""" browser = tabbed_browser_stubs[0] - browser.current_index = 0 - browser.index_of = RuntimeError + browser.widget.current_index = 0 + browser.widget.index_of = RuntimeError objects.signaller.signal.emit('foo') assert objects.signaller.filtered_signal_arg is None diff --git a/tests/unit/browser/test_tab.py b/tests/unit/browser/test_tab.py deleted file mode 100644 index 05a178b81..000000000 --- a/tests/unit/browser/test_tab.py +++ /dev/null @@ -1,110 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2016-2018 Florian Bruhin (The Compiler) -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see . - -import pytest - -from qutebrowser.browser import browsertab -from qutebrowser.utils import utils - -pytestmark = pytest.mark.usefixtures('redirect_webengine_data') - -try: - from PyQt5.QtWebKitWidgets import QWebView -except ImportError: - QWebView = None - -try: - from PyQt5.QtWebEngineWidgets import QWebEngineView -except ImportError: - QWebEngineView = None - - -@pytest.fixture(params=[QWebView, QWebEngineView]) -def view(qtbot, config_stub, request): - if request.param is None: - pytest.skip("View not available") - - v = request.param() - qtbot.add_widget(v) - return v - - -@pytest.fixture(params=['webkit', 'webengine']) -def tab(request, qtbot, tab_registry, cookiejar_and_cache, mode_manager): - if request.param == 'webkit': - webkittab = pytest.importorskip('qutebrowser.browser.webkit.webkittab') - tab_class = webkittab.WebKitTab - elif request.param == 'webengine': - webenginetab = pytest.importorskip( - 'qutebrowser.browser.webengine.webenginetab') - tab_class = webenginetab.WebEngineTab - else: - raise utils.Unreachable - - t = tab_class(win_id=0, mode_manager=mode_manager) - qtbot.add_widget(t) - yield t - - -class Zoom(browsertab.AbstractZoom): - - def _set_factor_internal(self, _factor): - pass - - def factor(self): - raise utils.Unreachable - - -class Tab(browsertab.AbstractTab): - - # pylint: disable=abstract-method - - def __init__(self, win_id, mode_manager, parent=None): - super().__init__(win_id=win_id, mode_manager=mode_manager, - parent=parent) - self.history = browsertab.AbstractHistory(self) - self.scroller = browsertab.AbstractScroller(self, parent=self) - self.caret = browsertab.AbstractCaret(mode_manager=mode_manager, - tab=self, parent=self) - self.zoom = Zoom(tab=self) - self.search = browsertab.AbstractSearch(parent=self) - self.printing = browsertab.AbstractPrinting() - self.elements = browsertab.AbstractElements(tab=self) - self.action = browsertab.AbstractAction(tab=self) - - def _install_event_filter(self): - pass - - -@pytest.mark.xfail(run=False, reason='Causes segfaults, see #1638') -def test_tab(qtbot, view, config_stub, tab_registry, mode_manager): - tab_w = Tab(win_id=0, mode_manager=mode_manager) - qtbot.add_widget(tab_w) - - assert tab_w.win_id == 0 - assert tab_w._widget is None - - tab_w._set_widget(view) - assert tab_w._widget is view - assert tab_w.history._tab is tab_w - assert tab_w.history._history is view.history() - assert view.parent() is tab_w - - with qtbot.waitExposed(tab_w): - tab_w.show() diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py index 00549f7f7..e396e168b 100644 --- a/tests/unit/browser/webengine/test_webenginesettings.py +++ b/tests/unit/browser/webengine/test_webenginesettings.py @@ -40,9 +40,7 @@ def test_big_cache_size(config_stub): """Make sure a too big cache size is handled correctly.""" config_stub.val.content.cache.size = 2 ** 63 - 1 profile = webenginesettings.default_profile - - webenginesettings._set_http_cache_size(profile) - + profile.setter.set_http_cache_size() assert profile.httpCacheMaximumSize() == 2 ** 31 - 1 diff --git a/tests/unit/commands/test_userscripts.py b/tests/unit/commands/test_userscripts.py index cfbfa0a86..1e4aadbdd 100644 --- a/tests/unit/commands/test_userscripts.py +++ b/tests/unit/commands/test_userscripts.py @@ -97,10 +97,11 @@ def test_custom_env(qtbot, monkeypatch, py_proc, runner): f.write('\n') """) - with qtbot.waitSignal(runner.got_cmd, timeout=10000) as blocker: - runner.prepare_run(cmd, *args, env=env) - runner.store_html('') - runner.store_text('') + with qtbot.waitSignal(runner.finished, timeout=10000): + with qtbot.waitSignal(runner.got_cmd, timeout=10000) as blocker: + runner.prepare_run(cmd, *args, env=env) + runner.store_html('') + runner.store_text('') data = blocker.args[0] ret_env = json.loads(data) diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index e14c5a466..af0a1ca62 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -539,12 +539,12 @@ def test_session_completion(qtmodeltester, session_manager_stub): def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs): - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2), ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] model = miscmodels.buffer() @@ -567,12 +567,12 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs): """Verify closing a tab by deleting it from the completion widget.""" - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] model = miscmodels.buffer() @@ -588,19 +588,19 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, assert model.data(idx) == '0/2' model.delete_cur_item(idx) - actual = [tab.url() for tab in tabbed_browser_stubs[0].tabs] + actual = [tab.url() for tab in tabbed_browser_stubs[0].widget.tabs] assert actual == [QUrl('https://github.com'), QUrl('https://duckduckgo.com')] def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, tabbed_browser_stubs, info): - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2), ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), ] info.win_id = 1 @@ -618,14 +618,37 @@ def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub, }) +def test_other_buffer_completion_id0(qtmodeltester, fake_web_tab, app_stub, + win_registry, tabbed_browser_stubs, info): + tabbed_browser_stubs[0].widget.tabs = [ + fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), + fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), + fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2), + ] + tabbed_browser_stubs[1].widget.tabs = [ + fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), + ] + info.win_id = 0 + model = miscmodels.other_buffer(info=info) + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + '1': [ + ('1/1', 'https://wiki.archlinux.org', 'ArchWiki'), + ], + }) + + def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs, info): - tabbed_browser_stubs[0].tabs = [ + tabbed_browser_stubs[0].widget.tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2) ] - tabbed_browser_stubs[1].tabs = [ + tabbed_browser_stubs[1].widget.tabs = [ fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0) ] diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 9104c0f53..e1ef7ef94 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -339,6 +339,24 @@ class TestKeyConfig: key_config_stub.unbind(seq) assert key_config_stub.get_command(seq, mode='normal') is None + def test_unbind_old_syntax(self, yaml_config_stub, key_config_stub, + config_stub): + """Test unbinding bindings added before the keybinding refactoring. + + We used to normalize keys differently, so we can have in the + config. + + See https://github.com/qutebrowser/qutebrowser/issues/3699 + """ + bindings = {'normal': {'': 'nop'}} + yaml_config_stub.set_obj('bindings.commands', bindings) + config_stub.read_yaml() + + key_config_stub.unbind(keyutils.KeySequence.parse(''), + save_yaml=True) + + assert config.instance.get_obj('bindings.commands') == {'normal': {}} + def test_empty_command(self, key_config_stub): """Try binding a key to an empty command.""" message = "Can't add binding 'x' with empty command in normal mode" diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 6434a1c9c..056bdb455 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -371,7 +371,8 @@ class TestEdit: """Tests for :config-edit.""" pytestmark = pytest.mark.usefixtures('config_tmpdir', 'data_tmpdir', - 'config_stub', 'key_config_stub') + 'config_stub', 'key_config_stub', + 'qapp') def test_no_source(self, commands, mocker): mock = mocker.patch('qutebrowser.config.configcommands.editor.' diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 96f5d4976..5a1fea72c 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -211,8 +211,11 @@ class TestYaml: data = autoconfig.read() assert data == {'tabs.show': {'global': 'value'}} - @pytest.mark.parametrize('persist', [True, False]) - def test_merge_persist(self, yaml, autoconfig, persist): + @pytest.mark.parametrize('persist, expected', [ + (True, 'persist'), + (False, 'normal'), + ]) + def test_merge_persist(self, yaml, autoconfig, persist, expected): """Tests for migration of tabs.persist_mode_on_change.""" autoconfig.write({'tabs.persist_mode_on_change': {'global': persist}}) yaml.load() @@ -220,8 +223,7 @@ class TestYaml: data = autoconfig.read() assert 'tabs.persist_mode_on_change' not in data - mode = 'persist' if persist else 'normal' - assert data['tabs.mode_on_change']['global'] == mode + assert data['tabs.mode_on_change']['global'] == expected def test_bindings_default(self, yaml, autoconfig): """Make sure bindings.default gets removed from autoconfig.yml.""" @@ -233,6 +235,23 @@ class TestYaml: data = autoconfig.read() assert 'bindings.default' not in data + @pytest.mark.parametrize('show, expected', [ + (True, 'always'), + (False, 'never'), + ('always', 'always'), + ('never', 'never'), + ('pinned', 'pinned'), + ]) + def test_tabs_favicons_show(self, yaml, autoconfig, show, expected): + """Tests for migration of tabs.favicons.show.""" + autoconfig.write({'tabs.favicons.show': {'global': show}}) + + yaml.load() + yaml._save() + + data = autoconfig.read() + assert data['tabs.favicons.show']['global'] == expected + def test_renamed_key_unknown_target(self, monkeypatch, yaml, autoconfig): """A key marked as renamed with invalid name should raise an error.""" diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index e7d217d8e..6f3f0e1e5 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -292,7 +292,7 @@ class TestEarlyInit: 'QT_XCB_FORCE_SOFTWARE_OPENGL', '1'), ('qt.force_platform', 'toaster', 'QT_QPA_PLATFORM', 'toaster'), ('qt.highdpi', True, 'QT_AUTO_SCREEN_SCALE_FACTOR', '1'), - ('window.hide_wayland_decoration', True, + ('window.hide_decoration', True, 'QT_WAYLAND_DISABLE_WINDOWDECORATION', '1') ]) def test_env_vars(self, monkeypatch, config_stub, @@ -347,6 +347,12 @@ class TestQtArgs: mocker.patch.object(parser, 'exit', side_effect=Exception) return parser + @pytest.fixture(autouse=True) + def patch_version_check(self, monkeypatch): + """Make sure no --disable-shared-workers argument gets added.""" + monkeypatch.setattr(configinit.qtutils, 'version_check', + lambda version, compiled: True) + @pytest.mark.parametrize('args, expected', [ # No Qt arguments (['--debug'], [sys.argv[0]]), @@ -382,6 +388,15 @@ class TestQtArgs: config_stub.val.qt.args = ['bar'] assert configinit.qt_args(parsed) == [sys.argv[0], '--foo', '--bar'] + def test_shared_workers(self, config_stub, monkeypatch, parser): + monkeypatch.setattr(configinit.qtutils, 'version_check', + lambda version, compiled: False) + monkeypatch.setattr(configinit.objects, 'backend', + usertypes.Backend.QtWebEngine) + parsed = parser.parse_args([]) + expected = [sys.argv[0], '--disable-shared-workers'] + assert configinit.qt_args(parsed) == expected + @pytest.mark.parametrize('arg, confval, used', [ # overridden by commandline arg diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 4dd7837fb..533932981 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -533,6 +533,17 @@ class FlagListSubclass(configtypes.FlagList): 'foo', 'bar', 'baz') +class FromObjType(configtypes.BaseType): + + """Config type to test from_obj for List/Dict.""" + + def from_obj(self, value): + return int(value) + + def to_py(self, value): + return value + + class TestList: """Test List and FlagList.""" @@ -647,6 +658,12 @@ class TestList: with pytest.raises(AssertionError): typ.to_doc([['foo']]) + def test_from_obj_sub(self): + """Make sure the list calls from_obj() on sub-types.""" + typ = configtypes.List(valtype=FromObjType()) + value = typ.from_obj(['1', '2']) + assert value == [1, 2] + class TestFlagList: @@ -1665,6 +1682,13 @@ class TestDict: print(doc) assert doc == expected + def test_from_obj_sub(self): + """Make sure the dict calls from_obj() on sub-types.""" + typ = configtypes.Dict(keytype=configtypes.String(), + valtype=FromObjType()) + value = typ.from_obj({'1': '2'}) + assert value == {'1': 2} + def unrequired_class(**kwargs): return configtypes.File(required=False, **kwargs) diff --git a/tests/unit/javascript/conftest.py b/tests/unit/javascript/conftest.py index 254ead335..89db706e5 100644 --- a/tests/unit/javascript/conftest.py +++ b/tests/unit/javascript/conftest.py @@ -17,118 +17,42 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""pylint conftest file for javascript test.""" +"""pytest conftest file for javascript tests.""" import os import os.path -import logging import pytest import jinja2 from PyQt5.QtCore import QUrl -try: - from PyQt5.QtWebKit import QWebSettings - from PyQt5.QtWebKitWidgets import QWebPage -except ImportError: - # FIXME:qtwebengine Make these tests use the tab API - QWebSettings = None - QWebPage = None - -try: - from PyQt5.QtWebEngineWidgets import (QWebEnginePage, - QWebEngineSettings, - QWebEngineScript) -except ImportError: - QWebEnginePage = None - QWebEngineSettings = None - QWebEngineScript = None import helpers.utils -import qutebrowser.utils.debug from qutebrowser.utils import utils -if QWebPage is None: - TestWebPage = None -else: - class TestWebPage(QWebPage): - - """QWebPage subclass which overrides some test methods. - - Attributes: - _logger: The logger used for alerts. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self._logger = logging.getLogger('js-tests') - - def javaScriptAlert(self, _frame, msg): - """Log javascript alerts.""" - self._logger.info("js alert: {}".format(msg)) - - def javaScriptConfirm(self, _frame, msg): - """Fail tests on js confirm() as that should never happen.""" - pytest.fail("js confirm: {}".format(msg)) - - def javaScriptPrompt(self, _frame, msg, _default): - """Fail tests on js prompt() as that should never happen.""" - pytest.fail("js prompt: {}".format(msg)) - - def javaScriptConsoleMessage(self, msg, line, source): - """Fail tests on js console messages as they're used for errors.""" - pytest.fail("js console ({}:{}): {}".format(source, line, msg)) - -if QWebEnginePage is None: - TestWebEnginePage = None -else: - class TestWebEnginePage(QWebEnginePage): - - """QWebEnginePage which overrides javascript logging methods. - - Attributes: - _logger: The logger used for alerts. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self._logger = logging.getLogger('js-tests') - - def javaScriptAlert(self, _frame, msg): - """Log javascript alerts.""" - self._logger.info("js alert: {}".format(msg)) - - def javaScriptConfirm(self, _frame, msg): - """Fail tests on js confirm() as that should never happen.""" - pytest.fail("js confirm: {}".format(msg)) - - def javaScriptPrompt(self, _frame, msg, _default): - """Fail tests on js prompt() as that should never happen.""" - pytest.fail("js prompt: {}".format(msg)) - - def javaScriptConsoleMessage(self, level, msg, line, source): - """Fail tests on js console messages as they're used for errors.""" - pytest.fail("[{}] js console ({}:{}): {}".format( - qutebrowser.utils.debug.qenum_key( - QWebEnginePage, level), source, line, msg)) - - class JSTester: """Common subclass providing basic functionality for all JS testers. Attributes: - webview: The webview which is used. - _qtbot: The QtBot fixture from pytest-qt. + tab: The tab object which is used. + qtbot: The QtBot fixture from pytest-qt. _jinja_env: The jinja2 environment used to get templates. """ - def __init__(self, webview, qtbot): - self.webview = webview - self._qtbot = qtbot + def __init__(self, tab, qtbot, config_stub): + self.tab = tab + self.qtbot = qtbot loader = jinja2.FileSystemLoader(os.path.dirname(__file__)) self._jinja_env = jinja2.Environment(loader=loader, autoescape=True) + # Make sure error logging via JS fails tests + config_stub.val.content.javascript.log = { + 'info': 'info', + 'error': 'error', + 'unknown': 'error', + 'warning': 'error' + } def load(self, path, **kwargs): """Load and display the given jinja test data. @@ -139,9 +63,9 @@ class JSTester: **kwargs: Passed to jinja's template.render(). """ template = self._jinja_env.get_template(path) - with self._qtbot.waitSignal(self.webview.loadFinished, - timeout=2000) as blocker: - self.webview.setHtml(template.render(**kwargs)) + with self.qtbot.waitSignal(self.tab.load_finished, + timeout=2000) as blocker: + self.tab.set_html(template.render(**kwargs)) assert blocker.args == [True] def load_file(self, path: str, force: bool = False): @@ -161,77 +85,13 @@ class JSTester: url: The QUrl to load. force: Whether to force loading even if the file is invalid. """ - with self._qtbot.waitSignal(self.webview.loadFinished, - timeout=2000) as blocker: - self.webview.load(url) + with self.qtbot.waitSignal(self.tab.load_finished, + timeout=2000) as blocker: + self.tab.openurl(url) if not force: assert blocker.args == [True] - -class JSWebKitTester(JSTester): - - """Object returned by js_tester which provides test data and a webview. - - Attributes: - webview: The webview which is used. - _qtbot: The QtBot fixture from pytest-qt. - _jinja_env: The jinja2 environment used to get templates. - """ - - def __init__(self, webview, qtbot): - super().__init__(webview, qtbot) - self.webview.setPage(TestWebPage(self.webview)) - - def scroll_anchor(self, name): - """Scroll the main frame to the given anchor.""" - page = self.webview.page() - old_pos = page.mainFrame().scrollPosition() - page.mainFrame().scrollToAnchor(name) - new_pos = page.mainFrame().scrollPosition() - assert old_pos != new_pos - - def run_file(self, filename): - """Run a javascript file. - - Args: - filename: The javascript filename, relative to - qutebrowser/javascript. - - Return: - The javascript return value. - """ - source = utils.read_file(os.path.join('javascript', filename)) - return self.run(source) - - def run(self, source): - """Run the given javascript source. - - Args: - source: The source to run as a string. - - Return: - The javascript return value. - """ - assert self.webview.settings().testAttribute( - QWebSettings.JavascriptEnabled) - return self.webview.page().mainFrame().evaluateJavaScript(source) - - -class JSWebEngineTester(JSTester): - - """Object returned by js_tester_webengine which provides a webview. - - Attributes: - webview: The webview which is used. - _qtbot: The QtBot fixture from pytest-qt. - _jinja_env: The jinja2 environment used to get templates. - """ - - def __init__(self, webview, qtbot): - super().__init__(webview, qtbot) - self.webview.setPage(TestWebEnginePage(self.webview)) - - def run_file(self, filename: str, expected) -> None: + def run_file(self, filename: str, expected=None) -> None: """Run a javascript file. Args: @@ -250,24 +110,24 @@ class JSWebEngineTester(JSTester): expected: The value expected return from the javascript execution world: The scope the javascript will run in """ - if world is None: - world = QWebEngineScript.ApplicationWorld - - callback_checker = helpers.utils.CallbackChecker(self._qtbot) - assert self.webview.settings().testAttribute( - QWebEngineSettings.JavascriptEnabled) - self.webview.page().runJavaScript(source, world, - callback_checker.callback) + callback_checker = helpers.utils.CallbackChecker(self.qtbot) + self.tab.run_js_async(source, callback_checker.callback, world=world) callback_checker.check(expected) @pytest.fixture -def js_tester_webkit(webview, qtbot): +def js_tester_webkit(webkit_tab, qtbot, config_stub): """Fixture to test javascript snippets in webkit.""" - return JSWebKitTester(webview, qtbot) + return JSTester(webkit_tab, qtbot, config_stub) @pytest.fixture -def js_tester_webengine(callback_checker, webengineview, qtbot): +def js_tester_webengine(webengine_tab, qtbot, config_stub): """Fixture to test javascript snippets in webengine.""" - return JSWebEngineTester(webengineview, qtbot) + return JSTester(webengine_tab, qtbot, config_stub) + + +@pytest.fixture +def js_tester(web_tab, qtbot, config_stub): + """Fixture to test javascript snippets with both backends.""" + return JSTester(web_tab, qtbot, config_stub) diff --git a/tests/unit/javascript/position_caret/test_position_caret.py b/tests/unit/javascript/position_caret/test_position_caret.py index c47f94e30..34b92dc66 100644 --- a/tests/unit/javascript/position_caret/test_position_caret.py +++ b/tests/unit/javascript/position_caret/test_position_caret.py @@ -21,12 +21,10 @@ import pytest -# FIXME:qtwebengine Make these tests use the tab API -pytest.importorskip('PyQt5.QtWebKit') +import helpers.utils -from PyQt5.QtCore import Qt -from PyQt5.QtWebKit import QWebSettings -from PyQt5.QtWebKitWidgets import QWebPage +QWebSettings = pytest.importorskip("PyQt5.QtWebKit").QWebSettings +QWebPage = pytest.importorskip("PyQt5.QtWebKitWidgets").QWebPage @pytest.fixture(autouse=True) @@ -53,15 +51,17 @@ class CaretTester: def check(self): """Check whether the caret is before the MARKER text.""" self.js.run_file('position_caret.js') - self.js.webview.triggerPageAction(QWebPage.SelectNextWord) - assert self.js.webview.selectedText().rstrip() == "MARKER" + self.js.tab.caret.toggle_selection() + self.js.tab.caret.move_to_next_word() + + callback_checker = helpers.utils.CallbackChecker(self.js.qtbot) + self.js.tab.caret.selection(lambda text: + callback_checker.callback(text.rstrip())) + callback_checker.check('MARKER') def check_scrolled(self): """Check if the page is scrolled down.""" - frame = self.js.webview.page().mainFrame() - minimum = frame.scrollBarMinimum(Qt.Vertical) - value = frame.scrollBarValue(Qt.Vertical) - assert value > minimum + assert not self.js.tab.scroller.at_top() @pytest.fixture @@ -70,7 +70,7 @@ def caret_tester(js_tester_webkit): caret_tester = CaretTester(js_tester_webkit) # Showing webview here is necessary for test_scrolled_down_img to # succeed in some cases, see #1988 - caret_tester.js.webview.show() + caret_tester.js.tab.show() return caret_tester @@ -82,10 +82,11 @@ def test_simple(caret_tester): @pytest.mark.integration +@pytest.mark.no_xvfb def test_scrolled_down(caret_tester): """Test with multiple text blocks with the viewport scrolled down.""" caret_tester.js.load('position_caret/scrolled_down.html') - caret_tester.js.scroll_anchor('anchor') + caret_tester.js.tab.scroller.to_anchor('anchor') caret_tester.check_scrolled() caret_tester.check() @@ -99,9 +100,10 @@ def test_invisible(caret_tester, style): @pytest.mark.integration +@pytest.mark.no_xvfb def test_scrolled_down_img(caret_tester): """Test with an image at the top with the viewport scrolled down.""" caret_tester.js.load('position_caret/scrolled_down_img.html') - caret_tester.js.scroll_anchor('anchor') + caret_tester.js.tab.scroller.to_anchor('anchor') caret_tester.check_scrolled() caret_tester.check() diff --git a/tests/unit/javascript/stylesheet/test_stylesheet.py b/tests/unit/javascript/stylesheet/test_stylesheet.py index d640e8a19..591d2cd73 100644 --- a/tests/unit/javascript/stylesheet/test_stylesheet.py +++ b/tests/unit/javascript/stylesheet/test_stylesheet.py @@ -27,11 +27,6 @@ QWebEngineProfile = QtWebEngineWidgets.QWebEngineProfile from qutebrowser.utils import javascript -try: - from qutebrowser.browser.webengine import webenginesettings -except ImportError: - webenginesettings = None - DEFAULT_BODY_BG = "rgba(0, 0, 0, 0)" GREEN_BODY_BG = "rgb(0, 255, 0)" @@ -56,8 +51,6 @@ class StylesheetTester: """Initialize the stylesheet with a provided css file.""" css_path = os.path.join(os.path.dirname(__file__), css_file) self.config_stub.val.content.user_stylesheets = css_path - p = QWebEngineProfile.defaultProfile() - webenginesettings._init_stylesheet(p) def set_css(self, css): """Set document style to `css` via stylesheet.js.""" @@ -67,10 +60,12 @@ class StylesheetTester: def check_set(self, value, css_style="background-color", document_element="document.body"): """Check whether the css in ELEMENT is set to VALUE.""" - self.js.run("window.getComputedStyle({}, null)" - ".getPropertyValue('{}');" - .format(document_element, - javascript.string_escape(css_style)), value) + self.js.run("console.log({document});" + "window.getComputedStyle({document}, null)" + ".getPropertyValue('{prop}');".format( + document=document_element, + prop=javascript.string_escape(css_style)), + value) def check_eq(self, one, two, true=True): """Check if one and two are equal.""" @@ -81,7 +76,7 @@ class StylesheetTester: def stylesheet_tester(js_tester_webengine, config_stub): """Helper fixture to test stylesheets.""" ss_tester = StylesheetTester(js_tester_webengine, config_stub) - ss_tester.js.webview.show() + ss_tester.js.tab.show() return ss_tester @@ -89,8 +84,8 @@ def stylesheet_tester(js_tester_webengine, config_stub): 'stylesheet/simple_bg_set_red.html']) def test_set_delayed(stylesheet_tester, page): """Test a delayed invocation of set_css.""" - stylesheet_tester.init_stylesheet("none.css") stylesheet_tester.js.load(page) + stylesheet_tester.init_stylesheet("none.css") stylesheet_tester.set_css("body {background-color: rgb(0, 255, 0);}") stylesheet_tester.check_set("rgb(0, 255, 0)") @@ -99,8 +94,8 @@ def test_set_delayed(stylesheet_tester, page): 'stylesheet/simple_bg_set_red.html']) def test_set_clear_bg(stylesheet_tester, page): """Test setting and clearing the stylesheet.""" - stylesheet_tester.init_stylesheet() stylesheet_tester.js.load('stylesheet/simple.html') + stylesheet_tester.init_stylesheet() stylesheet_tester.check_set(GREEN_BODY_BG) stylesheet_tester.set_css("") stylesheet_tester.check_set(DEFAULT_BODY_BG) @@ -108,31 +103,34 @@ def test_set_clear_bg(stylesheet_tester, page): def test_set_xml(stylesheet_tester): """Test stylesheet is applied without altering xml files.""" - stylesheet_tester.init_stylesheet() stylesheet_tester.js.load_file('stylesheet/simple.xml') + stylesheet_tester.init_stylesheet() stylesheet_tester.check_set(GREEN_BODY_BG) stylesheet_tester.check_eq('"html"', "document.documentElement.nodeName") def test_set_svg(stylesheet_tester): """Test stylesheet is applied for svg files.""" - stylesheet_tester.init_stylesheet() stylesheet_tester.js.load_file('../../../misc/cheatsheet.svg') + stylesheet_tester.init_stylesheet() stylesheet_tester.check_set(GREEN_BODY_BG, document_element="document.documentElement") stylesheet_tester.check_eq('"svg"', "document.documentElement.nodeName") -def test_set_error(stylesheet_tester): +@pytest.mark.skip(reason="Too flaky, see #3771") +def test_set_error(stylesheet_tester, config_stub): """Test stylesheet modifies file not found error pages.""" + config_stub.changed.disconnect() # This test is flaky otherwise... stylesheet_tester.init_stylesheet() + stylesheet_tester.js.tab._init_stylesheet() stylesheet_tester.js.load_file('non-existent.html', force=True) stylesheet_tester.check_set(GREEN_BODY_BG) def test_appendchild(stylesheet_tester): - stylesheet_tester.init_stylesheet() stylesheet_tester.js.load('stylesheet/simple.html') + stylesheet_tester.init_stylesheet() js_test_file_path = ('../../tests/unit/javascript/stylesheet/' 'test_appendchild.js') stylesheet_tester.js.run_file(js_test_file_path, {}) diff --git a/tests/unit/browser/webkit/test_qt_javascript.py b/tests/unit/javascript/test_js_execution.py similarity index 72% rename from tests/unit/browser/webkit/test_qt_javascript.py rename to tests/unit/javascript/test_js_execution.py index 901c373c4..c5b5018d2 100644 --- a/tests/unit/browser/webkit/test_qt_javascript.py +++ b/tests/unit/javascript/test_js_execution.py @@ -47,15 +47,32 @@ def test_element_js_webkit(webview, js_enabled, expected): @pytest.mark.usefixtures('redirect_webengine_data') -@pytest.mark.parametrize('js_enabled, expected', [(True, 2.0), (False, 2.0)]) -def test_simple_js_webengine(callback_checker, webengineview, js_enabled, - expected): +@pytest.mark.parametrize('js_enabled, world, expected', [ + # main world + (True, 0, 2.0), + (False, 0, None), + # application world + (True, 1, 2.0), + (False, 1, 2.0), + # user world + (True, 2, 2.0), + (False, 2, 2.0), +]) +def test_simple_js_webengine(callback_checker, webengineview, qapp, + js_enabled, world, expected): """With QtWebEngine, runJavaScript works even when JS is off.""" # If we get there (because of the webengineview fixture) we can be certain # QtWebEngine is available - from PyQt5.QtWebEngineWidgets import QWebEngineSettings - webengineview.settings().setAttribute(QWebEngineSettings.JavascriptEnabled, - js_enabled) + from PyQt5.QtWebEngineWidgets import QWebEngineSettings, QWebEngineScript - webengineview.page().runJavaScript('1 + 1', callback_checker.callback) + assert world in [QWebEngineScript.MainWorld, + QWebEngineScript.ApplicationWorld, + QWebEngineScript.UserWorld] + + settings = webengineview.settings() + settings.setAttribute(QWebEngineSettings.JavascriptEnabled, js_enabled) + qapp.processEvents() + + page = webengineview.page() + page.runJavaScript('1 + 1', world, callback_checker.callback) callback_checker.check(expected) diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 6465db875..7915e2b75 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -25,6 +25,7 @@ from PyQt5.QtCore import Qt import pytest from qutebrowser.keyinput import basekeyparser, keyutils +from qutebrowser.utils import utils # Alias because we need this a lot in here. @@ -153,14 +154,16 @@ class TestHandle: keyparser._read_config('prompt') def test_valid_key(self, fake_keyevent, keyparser): - keyparser.handle(fake_keyevent(Qt.Key_A, Qt.ControlModifier)) - keyparser.handle(fake_keyevent(Qt.Key_X, Qt.ControlModifier)) + modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier + keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) + keyparser.handle(fake_keyevent(Qt.Key_X, modifier)) keyparser.execute.assert_called_once_with('message-info ctrla', None) assert not keyparser._sequence def test_valid_key_count(self, fake_keyevent, keyparser): + modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier keyparser.handle(fake_keyevent(Qt.Key_5)) - keyparser.handle(fake_keyevent(Qt.Key_A, Qt.ControlModifier)) + keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) keyparser.execute.assert_called_once_with('message-info ctrla', 5) @pytest.mark.parametrize('keys', [ @@ -198,13 +201,34 @@ class TestHandle: keyparser.execute.assert_called_with('message-info ba', None) assert not keyparser._sequence - @pytest.mark.parametrize('key, number', [(Qt.Key_0, 0), (Qt.Key_1, 1)]) - def test_number_press(self, handle_text, keyparser, key, number): - handle_text(key) + @pytest.mark.parametrize('key, modifiers, number', [ + (Qt.Key_0, Qt.NoModifier, 0), + (Qt.Key_1, Qt.NoModifier, 1), + (Qt.Key_1, Qt.KeypadModifier, 1), + ]) + def test_number_press(self, fake_keyevent, keyparser, + key, modifiers, number): + keyparser.handle(fake_keyevent(key, modifiers)) command = 'message-info {}'.format(number) keyparser.execute.assert_called_once_with(command, None) assert not keyparser._sequence + @pytest.mark.parametrize('modifiers, text', [ + (Qt.NoModifier, '2'), + (Qt.KeypadModifier, 'num-2'), + ]) + def test_number_press_keypad(self, fake_keyevent, keyparser, config_stub, + modifiers, text): + """Make sure a binding overrides the 2 binding.""" + config_stub.val.bindings.commands = {'normal': { + '2': 'message-info 2', + '': 'message-info num-2'}} + keyparser._read_config('normal') + keyparser.handle(fake_keyevent(Qt.Key_2, modifiers)) + command = 'message-info {}'.format(text) + keyparser.execute.assert_called_once_with(command, None) + assert not keyparser._sequence + def test_umlauts(self, handle_text, keyparser, config_stub): config_stub.val.bindings.commands = {'normal': {'ü': 'message-info ü'}} keyparser._read_config('normal') @@ -215,6 +239,15 @@ class TestHandle: handle_text(Qt.Key_X) keyparser.execute.assert_called_once_with('message-info a', None) + def test_mapping_keypad(self, config_stub, fake_keyevent, keyparser): + """Make sure falling back to non-numpad keys works with mappings.""" + config_stub.val.bindings.commands = {'normal': {'a': 'nop'}} + config_stub.val.bindings.key_mappings = {'1': 'a'} + keyparser._read_config('normal') + + keyparser.handle(fake_keyevent(Qt.Key_1, Qt.KeypadModifier)) + keyparser.execute.assert_called_once_with('nop', None) + def test_binding_and_mapping(self, config_stub, handle_text, keyparser): """with a conflicting binding/mapping, the binding should win.""" handle_text(Qt.Key_B) @@ -296,6 +329,15 @@ class TestCount: assert sig1.args == ('4',) assert sig2.args == ('42',) + def test_numpad(self, fake_keyevent, keyparser): + """Make sure we can enter a count via numpad.""" + for key, modifiers in [(Qt.Key_4, Qt.KeypadModifier), + (Qt.Key_2, Qt.KeypadModifier), + (Qt.Key_B, Qt.NoModifier), + (Qt.Key_A, Qt.NoModifier)]: + keyparser.handle(fake_keyevent(key, modifiers)) + keyparser.execute.assert_called_once_with('message-info ba', 42) + def test_clear_keystring(qtbot, keyparser): """Test that the keystring is cleared and the signal is emitted.""" diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py index 0bc78ca12..dc8fe0a53 100644 --- a/tests/unit/keyinput/test_keyutils.py +++ b/tests/unit/keyinput/test_keyutils.py @@ -28,6 +28,7 @@ from PyQt5.QtWidgets import QWidget from tests.unit.keyinput import key_data from qutebrowser.keyinput import keyutils +from qutebrowser.utils import utils @pytest.fixture(params=key_data.KEYS, ids=lambda k: k.attribute) @@ -346,20 +347,28 @@ class TestKeySequence: @pytest.mark.parametrize('old, key, modifiers, text, expected', [ ('a', Qt.Key_B, Qt.NoModifier, 'b', 'ab'), ('a', Qt.Key_B, Qt.ShiftModifier, 'B', 'aB'), - ('a', Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier, 'B', - 'a'), + ('a', Qt.Key_B, Qt.AltModifier | Qt.ShiftModifier, 'B', + 'a'), # Modifier stripping with symbols ('', Qt.Key_Colon, Qt.NoModifier, ':', ':'), ('', Qt.Key_Colon, Qt.ShiftModifier, ':', ':'), - ('', Qt.Key_Colon, Qt.ControlModifier | Qt.ShiftModifier, ':', - ''), + ('', Qt.Key_Colon, Qt.AltModifier | Qt.ShiftModifier, ':', + ''), + + # Swapping Control/Meta on macOS + ('', Qt.Key_A, Qt.ControlModifier, '', + '' if utils.is_mac else ''), + ('', Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, '', + '' if utils.is_mac else ''), + ('', Qt.Key_A, Qt.MetaModifier, '', + '' if utils.is_mac else ''), # Handling of Backtab ('', Qt.Key_Backtab, Qt.NoModifier, '', ''), ('', Qt.Key_Backtab, Qt.ShiftModifier, '', ''), - ('', Qt.Key_Backtab, Qt.ControlModifier | Qt.ShiftModifier, '', - ''), + ('', Qt.Key_Backtab, Qt.AltModifier | Qt.ShiftModifier, '', + ''), # Stripping of Qt.GroupSwitchModifier ('', Qt.Key_A, Qt.GroupSwitchModifier, 'a', 'a'), @@ -370,6 +379,27 @@ class TestKeySequence: new = seq.append_event(event) assert new == keyutils.KeySequence.parse(expected) + @pytest.mark.fake_os('mac') + @pytest.mark.parametrize('modifiers, expected', [ + (Qt.ControlModifier, + Qt.MetaModifier), + (Qt.MetaModifier, + Qt.ControlModifier), + (Qt.ControlModifier | Qt.MetaModifier, + Qt.ControlModifier | Qt.MetaModifier), + (Qt.ControlModifier | Qt.ShiftModifier, + Qt.MetaModifier | Qt.ShiftModifier), + (Qt.MetaModifier | Qt.ShiftModifier, + Qt.ControlModifier | Qt.ShiftModifier), + (Qt.ShiftModifier, Qt.ShiftModifier), + ]) + def test_fake_mac(self, fake_keyevent, modifiers, expected): + """Make sure Control/Meta are swapped with a simulated Mac.""" + seq = keyutils.KeySequence() + event = fake_keyevent(key=Qt.Key_A, modifiers=modifiers) + new = seq.append_event(event) + assert new[0] == keyutils.KeyInfo(Qt.Key_A, expected) + @pytest.mark.parametrize('key', [Qt.Key_unknown, 0x0]) def test_append_event_invalid(self, key): seq = keyutils.KeySequence() @@ -377,6 +407,15 @@ class TestKeySequence: with pytest.raises(keyutils.KeyParseError): seq.append_event(event) + def test_strip_modifiers(self): + seq = keyutils.KeySequence(Qt.Key_0, + Qt.Key_1 | Qt.KeypadModifier, + Qt.Key_A | Qt.ControlModifier) + expected = keyutils.KeySequence(Qt.Key_0, + Qt.Key_1, + Qt.Key_A | Qt.ControlModifier) + assert seq.strip_modifiers() == expected + def test_with_mappings(self): seq = keyutils.KeySequence.parse('foobar') mappings = {keyutils.KeySequence('b'): keyutils.KeySequence('t')} @@ -479,6 +518,8 @@ def test_is_printable(key, printable): (Qt.Key_Escape, Qt.ControlModifier, True), (Qt.Key_X, Qt.ControlModifier, True), (Qt.Key_X, Qt.NoModifier, False), + (Qt.Key_2, Qt.KeypadModifier, False), + (Qt.Key_2, Qt.NoModifier, False), ]) def test_is_special(key, modifiers, special): assert keyutils.is_special(key, modifiers) == special diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index cd1f110bc..72f32af32 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -96,3 +96,11 @@ class TestHintKeyParser: assert match == QKeySequence.ExactMatch keyparser.execute.assert_called_with('follow-hint -s as', None) + + def test_numberkey_hint_match(self, keyparser, fake_keyevent): + keyparser.update_bindings(['21', '22']) + + match = keyparser.handle(fake_keyevent(Qt.Key_2, Qt.KeypadModifier)) + assert match == QKeySequence.PartialMatch + match = keyparser.handle(fake_keyevent(Qt.Key_2, Qt.KeypadModifier)) + assert match == QKeySequence.ExactMatch diff --git a/tests/unit/mainwindow/statusbar/test_backforward.py b/tests/unit/mainwindow/statusbar/test_backforward.py index 6e594c0d2..11e3da616 100644 --- a/tests/unit/mainwindow/statusbar/test_backforward.py +++ b/tests/unit/mainwindow/statusbar/test_backforward.py @@ -43,8 +43,8 @@ def test_backforward_widget(backforward_widget, tabbed_browser_stubs, """Ensure the Backforward widget shows the correct text.""" tab = fake_web_tab(can_go_back=can_go_back, can_go_forward=can_go_forward) tabbed_browser = tabbed_browser_stubs[0] - tabbed_browser.current_index = 1 - tabbed_browser.tabs = [tab] + tabbed_browser.widget.current_index = 1 + tabbed_browser.widget.tabs = [tab] backforward_widget.enabled = True backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == expected_text @@ -59,7 +59,7 @@ def test_backforward_widget(backforward_widget, tabbed_browser_stubs, # Check that the widget gets reset if empty. if can_go_back and can_go_forward: tab = fake_web_tab(can_go_back=False, can_go_forward=False) - tabbed_browser.tabs = [tab] + tabbed_browser.widget.tabs = [tab] backforward_widget.enabled = True backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == '' @@ -70,15 +70,15 @@ def test_none_tab(backforward_widget, tabbed_browser_stubs, fake_web_tab): """Make sure nothing crashes when passing None as tab.""" tab = fake_web_tab(can_go_back=True, can_go_forward=True) tabbed_browser = tabbed_browser_stubs[0] - tabbed_browser.current_index = 1 - tabbed_browser.tabs = [tab] + tabbed_browser.widget.current_index = 1 + tabbed_browser.widget.tabs = [tab] backforward_widget.enabled = True backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == '[<>]' assert backforward_widget.isVisible() - tabbed_browser.current_index = -1 + tabbed_browser.widget.current_index = -1 backforward_widget.on_tab_cur_url_changed(tabbed_browser) assert backforward_widget.text() == '' diff --git a/tests/unit/mainwindow/statusbar/test_textbase.py b/tests/unit/mainwindow/statusbar/test_textbase.py index c73a87831..9299e3a5f 100644 --- a/tests/unit/mainwindow/statusbar/test_textbase.py +++ b/tests/unit/mainwindow/statusbar/test_textbase.py @@ -63,7 +63,7 @@ def test_settext_empty(mocker, qtbot): autospec=True) label.setText('') - label.repaint.assert_called_with() + label.repaint.assert_called_with() # pylint: disable=no-member def test_resize(qtbot): @@ -92,7 +92,7 @@ def test_text_elide_none(mocker, qtbot): 'fontMetrics') label._update_elided_text(20) - assert not label.fontMetrics.called + assert not label.fontMetrics.called # pylint: disable=no-member def test_unset_text(qtbot): diff --git a/tests/unit/mainwindow/test_prompt.py b/tests/unit/mainwindow/test_prompt.py index e467b0316..7c8d2b0ad 100644 --- a/tests/unit/mainwindow/test_prompt.py +++ b/tests/unit/mainwindow/test_prompt.py @@ -81,6 +81,14 @@ class TestFileCompletion: for _ in range(3): qtbot.keyPress(prompt._lineedit, Qt.Key_Backspace) + # foo should get completed from f + prompt.item_focus('next') + assert prompt._lineedit.text() == str(testdir / 'foo') + + # Deleting /[foo] + for _ in range(3): + qtbot.keyPress(prompt._lineedit, Qt.Key_Backspace) + # We should now show / again, so tabbing twice gives us .. -> bar prompt.item_focus('next') prompt.item_focus('next') diff --git a/tests/unit/mainwindow/test_tabwidget.py b/tests/unit/mainwindow/test_tabwidget.py index 7ad22fcc3..de68481a9 100644 --- a/tests/unit/mainwindow/test_tabwidget.py +++ b/tests/unit/mainwindow/test_tabwidget.py @@ -61,6 +61,60 @@ class TestTabWidget: with qtbot.waitExposed(widget): widget.show() + # Sizing tests + + def test_tab_size_same(self, widget, fake_web_tab): + """Ensure by default, all tab sizes are the same.""" + num_tabs = 10 + for i in range(num_tabs): + widget.addTab(fake_web_tab(), 'foobar' + str(i)) + + first_size = widget.tabBar().tabSizeHint(0) + first_size_min = widget.tabBar().minimumTabSizeHint(0) + + for i in range(num_tabs): + assert first_size == widget.tabBar().tabSizeHint(i) + assert first_size_min == widget.tabBar().minimumTabSizeHint(i) + + @pytest.mark.parametrize("shrink_pinned", [True, False]) + @pytest.mark.parametrize("vertical", [True, False]) + def test_pinned_size(self, widget, fake_web_tab, config_stub, + shrink_pinned, vertical): + """Ensure by default, pinned min sizes are forced to title. + + If pinned.shrink is not true, then all tabs should be the same + + If tabs are vertical, all tabs should be the same""" + num_tabs = 10 + for i in range(num_tabs): + widget.addTab(fake_web_tab(), 'foobar' + str(i)) + + # Set pinned title format longer than unpinned + config_stub.val.tabs.title.format_pinned = "_" * 20 + config_stub.val.tabs.title.format = "_" * 2 + config_stub.val.tabs.pinned.shrink = shrink_pinned + if vertical: + # Use pixel width so we don't need to mock main-window + config_stub.val.tabs.width = 50 + config_stub.val.tabs.position = "left" + + pinned_num = [1, num_tabs - 1] + for tab in pinned_num: + widget.set_tab_pinned(widget.widget(tab), True) + + first_size = widget.tabBar().tabSizeHint(0) + first_size_min = widget.tabBar().minimumTabSizeHint(0) + + for i in range(num_tabs): + if i in pinned_num and shrink_pinned and not vertical: + assert (first_size.width() < + widget.tabBar().tabSizeHint(i).width()) + assert (first_size_min.width() < + widget.tabBar().minimumTabSizeHint(i).width()) + else: + assert first_size == widget.tabBar().tabSizeHint(i) + assert first_size_min == widget.tabBar().minimumTabSizeHint(i) + @pytest.mark.parametrize("num_tabs", [4, 10]) def test_update_tab_titles_benchmark(self, benchmark, widget, qtbot, fake_web_tab, num_tabs): @@ -71,7 +125,7 @@ class TestTabWidget: with qtbot.waitExposed(widget): widget.show() - benchmark(widget._update_tab_titles) + benchmark(widget.update_tab_titles) @pytest.mark.parametrize("num_tabs", [4, 10]) def test_add_remove_tab_benchmark(self, benchmark, browser, @@ -79,7 +133,7 @@ class TestTabWidget: """Benchmark for addTab and removeTab.""" def _run_bench(): for i in range(num_tabs): - browser.addTab(fake_web_tab(), 'foobar' + str(i)) + browser.widget.addTab(fake_web_tab(), 'foobar' + str(i)) with qtbot.waitExposed(browser): browser.show() diff --git a/tests/unit/misc/test_editor.py b/tests/unit/misc/test_editor.py index 8cf778d89..94021484a 100644 --- a/tests/unit/misc/test_editor.py +++ b/tests/unit/misc/test_editor.py @@ -157,6 +157,45 @@ class TestFileHandling: with pytest.raises(ValueError): editor.edit("") + def test_backup(self, qtbot, message_mock): + editor = editormod.ExternalEditor(watch=True) + editor.edit('foo') + with qtbot.wait_signal(editor.file_updated): + _update_file(editor._filename, 'bar') + + editor.backup() + + msg = message_mock.getmsg(usertypes.MessageLevel.info) + prefix = 'Editor backup at ' + assert msg.text.startswith(prefix) + fname = msg.text[len(prefix):] + + with qtbot.wait_signal(editor.editing_finished): + editor._proc.finished.emit(0, QProcess.NormalExit) + + with open(fname, 'r', encoding='utf-8') as f: + assert f.read() == 'bar' + + def test_backup_no_content(self, qtbot, message_mock): + editor = editormod.ExternalEditor(watch=True) + editor.edit('foo') + editor.backup() + # content has not changed, so no backup should be created + assert not message_mock.messages + + def test_backup_error(self, qtbot, message_mock, mocker, caplog): + editor = editormod.ExternalEditor(watch=True) + editor.edit('foo') + with qtbot.wait_signal(editor.file_updated): + _update_file(editor._filename, 'bar') + + mocker.patch('tempfile.NamedTemporaryFile', side_effect=OSError) + with caplog.at_level(logging.ERROR): + editor.backup() + + msg = message_mock.getmsg(usertypes.MessageLevel.error) + assert msg.text.startswith('Failed to create editor backup:') + @pytest.mark.parametrize('initial_text, edited_text', [ ('', 'Hello'), diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py index c45913a2a..7c9727b65 100644 --- a/tests/unit/misc/test_keyhints.py +++ b/tests/unit/misc/test_keyhints.py @@ -92,6 +92,32 @@ def test_suggestions(keyhint, config_stub): ('a', 'yellow', 'c', 'message-info cmd-ac')) +def test_suggestions_special(keyhint, config_stub): + """Test that special characters work properly as prefix.""" + bindings = {'normal': { + 'a': 'message-info cmd-Cca', + '': 'message-info cmd-CcCc', + '': 'message-info cmd-CcCx', + 'cbb': 'message-info cmd-cbb', + 'xd': 'message-info cmd-xd', + 'xe': 'message-info cmd-xe', + }} + default_bindings = {'normal': { + 'c': 'message-info cmd-Ccc', + }} + config_stub.val.bindings.default = default_bindings + config_stub.val.bindings.commands = bindings + + keyhint.update_keyhint('normal', '') + assert keyhint.text() == expected_text( + ('<Ctrl+c>', 'yellow', 'a', 'message-info cmd-Cca'), + ('<Ctrl+c>', 'yellow', 'c', 'message-info cmd-Ccc'), + ('<Ctrl+c>', 'yellow', '<Ctrl+c>', + 'message-info cmd-CcCc'), + ('<Ctrl+c>', 'yellow', '<Ctrl+x>', + 'message-info cmd-CcCx')) + + def test_suggestions_with_count(keyhint, config_stub, monkeypatch, stubs): """Test that a count prefix filters out commands that take no count.""" monkeypatch.setattr('qutebrowser.commands.cmdutils.cmd_dict', { diff --git a/tests/unit/misc/test_utilcmds.py b/tests/unit/misc/test_utilcmds.py index c63c7bf86..cfa115412 100644 --- a/tests/unit/misc/test_utilcmds.py +++ b/tests/unit/misc/test_utilcmds.py @@ -153,6 +153,6 @@ def tabbed_browser(stubs, win_registry): objreg.delete('tabbed-browser', scope='window', window=0) -def test_version(tabbed_browser): +def test_version(tabbed_browser, qapp): utilcmds.version(win_id=0) assert tabbed_browser.opened_url == QUrl('qute://version') diff --git a/tests/unit/utils/test_javascript.py b/tests/unit/utils/test_javascript.py index 864483bf0..29e090fd0 100644 --- a/tests/unit/utils/test_javascript.py +++ b/tests/unit/utils/test_javascript.py @@ -100,3 +100,12 @@ def test_convert_js_arg(arg, expected): def test_assemble(base, expected_base): expected = '"use strict";\n{}.func(23);'.format(expected_base) assert javascript.assemble(base, 'func', 23) == expected + + +def test_wrap_global(): + source = javascript.wrap_global('name', + 'console.log("foo");', + 'console.log("bar");') + assert 'window._qutebrowser.initialized["name"]' in source + assert 'console.log("foo");' in source + assert 'console.log("bar");' in source diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index 6621edfed..9df7ed0d0 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -278,6 +278,7 @@ def test_special_urls(url, special): assert urlutils.is_special_url(QUrl(url)) == special +@pytest.mark.parametrize('open_base_url', [True, False]) @pytest.mark.parametrize('url, host, query', [ ('testfoo', 'www.example.com', 'q=testfoo'), ('test testfoo', 'www.qutebrowser.org', 'q=testfoo'), @@ -288,7 +289,7 @@ def test_special_urls(url, special): ('stripped ', 'www.example.com', 'q=stripped'), ('test-with-dash testfoo', 'www.example.org', 'q=testfoo'), ]) -def test_get_search_url(url, host, query): +def test_get_search_url(config_stub, url, host, query, open_base_url): """Test _get_search_url(). Args: @@ -296,11 +297,32 @@ def test_get_search_url(url, host, query): host: The expected search machine host. query: The expected search query. """ + config_stub.val.url.open_base_url = open_base_url url = urlutils._get_search_url(url) assert url.host() == host assert url.query() == query +@pytest.mark.parametrize('url, host', [ + ('test', 'www.qutebrowser.org'), + ('test-with-dash', 'www.example.org'), +]) +def test_get_search_url_open_base_url(config_stub, url, host): + """Test _get_search_url() with url.open_base_url_enabled. + + Args: + url: The "URL" to enter. + host: The expected search machine host. + query: The expected search query. + """ + config_stub.val.url.open_base_url = True + url = urlutils._get_search_url(url) + assert not url.path() + assert not url.fragment() + assert not url.query() + assert url.host() == host + + @pytest.mark.parametrize('url', ['\n', ' ', '\n ']) def test_get_search_url_invalid(url): with pytest.raises(ValueError): diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 163df0e84..fe45fec97 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -963,7 +963,7 @@ def test_version_output(params, stubs, monkeypatch): assert version.version() == expected -def test_opengl_vendor(): +def test_opengl_vendor(qapp): """Simply call version.opengl_vendor() and see if it doesn't crash.""" pytest.importorskip("PyQt5.QtOpenGL") return version.opengl_vendor()