diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 7e2863113..000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -qutebrowser/3rdparty/pdfjs/* diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 70947143a..000000000 --- a/.eslintrc +++ /dev/null @@ -1,49 +0,0 @@ -# vim: ft=yaml - -env: - browser: true - -rules: - block-scoped-var: 2 - dot-location: 2 - default-case: 2 - guard-for-in: 2 - no-div-regex: 2 - no-param-reassign: 2 - no-eq-null: 2 - no-floating-decimal: 2 - no-self-compare: 2 - no-throw-literal: 2 - no-void: 2 - radix: 2 - wrap-iife: [2, "inside"] - brace-style: [2, "1tbs", {"allowSingleLine": true}] - comma-style: [2, "last"] - consistent-this: [2, "self"] - func-style: [2, "declaration"] - indent: [2, 4, {"SwitchCase": 1}] - linebreak-style: [2, "unix"] - max-nested-callbacks: [2, 3] - no-lonely-if: 2 - no-multiple-empty-lines: [2, {"max": 2}] - no-nested-ternary: 2 - no-unneeded-ternary: 2 - operator-assignment: [2, "always"] - operator-linebreak: [2, "after"] - keyword-spacing: 2 - space-before-blocks: [2, "always"] - space-before-function-paren: [2, {"anonymous": "never", "named": "never"}] - object-curly-spacing: [2, "never"] - array-bracket-spacing: [2, "never"] - computed-property-spacing: [2, "never"] - space-in-parens: [2, "never"] - space-unary-ops: [2, {"words": true, "nonwords": false}] - spaced-comment: [2, "always"] - max-depth: [2, 5] - max-len: [2, 79, 4] - max-params: [2, 5] - max-statements: [2, 30] - no-bitwise: 2 - quote-props: [2, "always"] - global-strict: 0 - quotes: 0 diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index f1a585a18..d16e7570a 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -28,10 +28,37 @@ Added completion category headers. - New `:debug-log-capacity` command to adjust how many lines are logged into RAM (to report bugs which are difficult to reproduce). +- New `hide-unmatched-rapid-hints` option to not hide hint unmatched hint labels + in rapid mode. +- New `{clipboard}` and `{primary}` replacements for the commandline which + replace the `:paste` command. +- New `:insert-text` command to insert a given text into a field on the page, + which replaces `:paste-primary` together with the `{primary}` replacement. +- New `:window-only` command to close all other windows. +- New `prev-category` and `next-category` arguments to `:completion-item-focus` + to focus the previous/next category in the completion (bound to `` + and `` by default). +- New `:click-element` command to fake a click on a element. +- New `:debug-log-filter` command to change console log filtering on-the-fly. +- New `:debug-log-level` command to change the console loglevel on-the-fly. Changed ~~~~~~~ +- Hints are now drawn natively in Qt instead of using web elements. This has a + few implications for users: + * The `hints -> opacity` setting does not exist anymore, but you can use + `rgba(r, g, b, alpha)` colors instead for `colors -> hints.bg`. + * The `hints -> font` setting is not affected by + `fonts -> web-family-fixed` anymore. Thus, a transformer got added to + change `Monospace` to `${_monospace}`. + * Gradients in hint colors can now be configured by using `qlineargradient` + and friends instead of `-webkit-gradient`. The most common cases get + migrated automatically, but if you drastically changed the defaults, + you'll need to manually adjust your config. + * Styling hints by styling `qutehint` elements in `user-stylesheet` was + never officially supported and does not work anymore. + * Hints are now not affected by the page's stylesheet or zoom anymore. - `:bookmark-add` now has a `--toggle` flag which deletes the bookmark if it already exists. - `:bookmark-load` now has a `--delete` flag which deletes the bookmark after @@ -51,6 +78,37 @@ Changed `:bind`) now don't immediately evaluate variables. - Tab titles in the `:buffer` completion now update correctly when a page's title is changed via javascript. +- `:hint` now has a `--mode ` flag to override the hint mode configured + using the `hints -> mode` setting. +- With `new-instance-open-target` set to a tab option, the tab is now opened in + the most recently focused (instead of the last opened) window. This can be + configured with the new `new-instance-open-target.window` setting. + It can also be set to `last-visible` to show the pages in the most recently + visible window, or `first-opened` to use the first (oldest) available window. +- Word hints now are more clever about getting the element text from some elements. +- Completions for `:help` and `:bind` now also show hidden commands +- The `:buffer` completion now also filters using the first column (id). +- `:undo` has been improved to reopen tabs at the position they were closed. +- `:navigate` now takes a count for `up`/`increment`/`decrement`. +- The `hints -> auto-follow` setting now can be set to + `always`/`full-match`/`unique-match`/`never` to more precisely control when + hints should be followed automatically. +- Counts can now be used with special keybindings (e.g. with modifiers). + This was already implemented for v0.7.0 originally, but got reverted because + it caused some issues and then never re-applied. +- Sending a command to an existing instance (via "qutebrowser :reload") now + doesn't mark it as urgent anymore. +- `tabs -> title-format` now treats an empty string as valid. +- Bindings for `:`, `/` and `?` are now configured explicitly and not hardcoded + anymore. + +Deprecated +~~~~~~~~~~ + +- The `:paste` command got deprecated as `:open` with `{clipboard}` and + `{primary}` can be used instead. +- The `:paste-primary` command got deprecated as `:insert-text {primary}` can + be used instead. Removed ~~~~~~~ @@ -59,6 +117,20 @@ Removed and thus removed. - The `:completion-item-prev` and `:completion-item-next` commands got merged into a new `:completion-focus {prev,next}` command and thus removed. +- The `ui -> hide-mouse-cursor` setting since it was completely broken and + nobody seemed to care. +- The `hints -> opacity` setting - see the "Changed" section for details. + +Fixed +~~~~~ + +- `:bind` can now be used to bind to an alias (binding by editing `keys.conf` + already worked before) +- The command completion now updates correctly when changing aliases +- `:undo` now doesn't undo tabs "closed" by `:tab-detach` anymore. +- Fixed an issue with hint chars not being cleared correctly when leaving hint + mode. +- `:tab-detach` now fails correctly when there's only one tab open. v0.8.3 (unreleased) ------------------- @@ -69,6 +141,7 @@ Fixed - Fixed crash when doing `:`, another corner-case introduced in v0.8.0 - Fixed `:open-editor` (``) on Windows - Fixed crash when setting `general -> auto-save-interval` to a too big value. +- Fixed crash when using hints on Void Linux. v0.8.2 ------ @@ -78,7 +151,6 @@ Fixed - Fixed `general -> private-browsing` not being set correctly until a restart (which caused e.g. local storage to be enabled). -- Fixed crash when using hints with JS disabled in some rare circumstances. - When hinting input fields (`:t`), also consider input elements without a type. - Fixed crash when opening an invalid URL with a percent-encoded and a real @ in it - Fixed default `;o` and `;O` bindings @@ -92,6 +164,7 @@ Fixed - Fixed crash when cancelling a download after doing `:prompt-open-download` - Fixed crash when writing a download to disk fails with `:prompt-open-download`. +- Fixed `:restart` deleting the basedir when it was given with `--basedir`. v0.8.1 ------ @@ -198,7 +271,6 @@ Changed - `:navigate` now clears the URL fragment - `:completion-item-del` (`Ctrl-D`) can now be used in `:buffer` completion to close a tab -- Counts can now be used with special keybindings (e.g. with modifiers) - Various SSL ciphers are now disabled by default. With recent Qt/OpenSSL versions those already all are disabled, but with older versions they might not be. diff --git a/MANIFEST.in b/MANIFEST.in index 00acb1054..822cc58f0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -35,8 +35,8 @@ exclude pytest.ini exclude qutebrowser.rcc exclude .coveragerc exclude .pylintrc -exclude .eslintrc -exclude .eslintignore +exclude qutebrowser/javascript/.eslintrc.yaml +exclude qutebrowser/javascript/.eslintignore exclude doc/help exclude .appveyor.yml exclude .travis.yml diff --git a/README.asciidoc b/README.asciidoc index 1012eeed2..6af28d775 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -142,15 +142,15 @@ Contributors, sorted by the number of commits in descending order: * Florian Bruhin * Daniel Schadt * Ryan Roden-Corrent +* Jakub Klinkovský * Antoni Boucher * Lamar Pavel +* Jan Verbeek * Bruno Oliveira * Alexander Cogneau * Marshall Lochbaum * Felix Van der Jeugt -* Jakub Klinkovský * Martin Tournoij -* Jan Verbeek * Raphael Pierzina * Joel Torstensson * Patric Schmitz @@ -166,21 +166,24 @@ Contributors, sorted by the number of commits in descending order: * Thorsten Wißmann * Austin Anderson * Jimmy +* Niklas Haas * Alexey "Averrin" Nabrodov * avk * ZDarian * Milan Svoboda * John ShaggyTwoDope Jenkins +* nanjekyejoannah * Peter Vilim * Clayton Craft -* nanjekyejoannah * Oliver Caldwell * Jonas Schürmann * error800 +* Michael Hoang * Liam BEGUIN * skinnay * Zach-Button * Tomasz Kramkowski +* Peter Rice * Ismail S * Halfwit * David Vogt @@ -196,6 +199,7 @@ Contributors, sorted by the number of commits in descending order: * Brian Jackson * sbinix * neeasade +* knaggita * jnphilipp * Tobias Patzl * Stefan Tatschner @@ -204,6 +208,7 @@ Contributors, sorted by the number of commits in descending order: * Panashe M. Fundira * Link * Larry Hynes +* Julian Weigt * Johannes Altmanninger * Jeremy Kaplan * Ismail @@ -220,7 +225,6 @@ Contributors, sorted by the number of commits in descending order: * zwarag * xd1le * oniondreams -* knaggita * issue * haxwithaxe * evan @@ -234,6 +238,7 @@ Contributors, sorted by the number of commits in descending order: * Thiago Barroso Perrotta * Sorokin Alexei * Noah Huesser +* Moez Bouhlel * Matthias Lisin * Marcel Schilling * Julie Engel diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 36c2d9653..eb76fa84f 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1,5 +1,21 @@ +// DO NOT EDIT THIS FILE DIRECTLY! +// It is autogenerated from docstrings by running: +// $ python3 scripts/dev/src2asciidoc.py + = Commands +In qutebrowser, all keybindings are mapped to commands. + +Some commands are hidden, which means they don't show up in the command +completion when pressing `:`, as they're typically not useful to run by hand. + +In the commandline, there are also some variables you can use: + +- `{url}` expands to the URL of the current page +- `{url:pretty}` expands to the URL in decoded format +- `{clipboard}` expands to the clipboard contents +- `{primary}` expands to the primary selection contents + == Normal commands .Quick reference [options="header",width="75%",cols="25%,75%"] @@ -28,13 +44,13 @@ |<>|Start hinting. |<>|Clear all browsing history. |<>|Open main startpage in current tab. +|<>|Insert text at cursor position. |<>|Toggle the web inspector. |<>|Evaluate a JavaScript string. |<>|Execute a command after some time. |<>|Show a log of past messages. |<>|Open typical prev/next links or navigate using the URL path. |<>|Open a URL in the current/[count]th tab. -|<>|Open a page from the clipboard. |<>|Print the current/[count]th tab. |<>|Add a new quickmark. |<>|Delete a quickmark. @@ -65,6 +81,7 @@ |<>|Unbind a keychain. |<>|Re-open a closed tab (optionally skipping [count] closed tabs). |<>|Show the source of the current page. +|<>|Close all windows except for the current one. |<>|Save open pages and quit. |<>|Yank something to the clipboard or primary selection. |<>|Set the zoom level for the current tab. @@ -320,12 +337,12 @@ Show help about a command or setting. [[hint]] === hint -Syntax: +:hint [*--rapid*] ['group'] ['target'] ['args' ['args' ...]]+ +Syntax: +:hint [*--rapid*] [*--mode* 'mode'] ['group'] ['target'] ['args' ['args' ...]]+ Start hinting. ==== positional arguments -* +'group'+: The hinting mode to use. +* +'group'+: The element types to hint. - `all`: All clickable elements. - `links`: Only links. @@ -376,6 +393,15 @@ Start hinting. * +*-r*+, +*--rapid*+: Whether to do rapid hinting. This is only possible with targets `tab` (with background-tabs=true), `tab-bg`, `window`, `run`, `hover`, `userscript` and `spawn`. +* +*-m*+, +*--mode*+: The hinting mode to use. + + - `number`: Use numeric hints. + - `letter`: Use the chars in the hints->chars settings. + - `word`: Use hint words based on the html elements and the + extra words. + + + ==== note * This command does not split arguments after the last argument and handles quotes literally. @@ -390,6 +416,18 @@ Note this only clears the global history (e.g. `~/.local/share/qutebrowser/histo === home Open main startpage in current tab. +[[insert-text]] +=== insert-text +Syntax: +:insert-text 'text'+ + +Insert text at cursor position. + +==== positional arguments +* +'text'+: The text to insert. + +==== note +* This command does not split arguments after the last argument and handles quotes literally. + [[inspector]] === inspector Toggle the web inspector. @@ -468,12 +506,18 @@ This tries to automatically click on typical _Previous Page_ or _Next Page_ link * +*-b*+, +*--bg*+: Open in a background tab. * +*-w*+, +*--window*+: Open in a new window. +==== count +For `increment` and `decrement`, the number to change the URL by. For `up`, the number of levels to go up in the URL. + + [[open]] === open Syntax: +:open [*--implicit*] [*--bg*] [*--tab*] [*--window*] ['url']+ Open a URL in the current/[count]th tab. +If the URL contains newlines, each line gets opened in its own tab. + ==== positional arguments * +'url'+: The URL to open. @@ -490,20 +534,6 @@ The tab index to open the URL in. ==== note * This command does not split arguments after the last argument and handles quotes literally. -[[paste]] -=== paste -Syntax: +:paste [*--sel*] [*--tab*] [*--bg*] [*--window*]+ - -Open a page from the clipboard. - -If the pasted text contains newlines, each line gets opened in its own tab. - -==== optional arguments -* +*-s*+, +*--sel*+: Use the primary selection instead of the clipboard. -* +*-t*+, +*--tab*+: Open in a new tab. -* +*-b*+, +*--bg*+: Open in a background tab. -* +*-w*+, +*--window*+: Open in new window. - [[print]] === print Syntax: +:print [*--preview*] [*--pdf* 'file']+ @@ -844,6 +874,10 @@ Re-open a closed tab (optionally skipping [count] closed tabs). === view-source Show the source of the current page. +[[window-only]] +=== window-only +Close all windows except for the current one. + [[wq]] === wq Syntax: +:wq ['name']+ @@ -910,6 +944,7 @@ How many steps to zoom out. |============== |Command|Description |<>|Clear the currently entered key chain. +|<>|Click the element matching the given filter. |<>|Execute the command currently in the commandline. |<>|Go forward in the commandline history. |<>|Go back in the commandline history. @@ -940,7 +975,6 @@ How many steps to zoom out. |<>|Move the cursor or selection to the start of next block. |<>|Move the cursor or selection to the start of previous block. |<>|Open an external editor with the currently selected form field. -|<>|Paste the primary selection at cursor position. |<>|Accept the current prompt. |<>|Answer no to a yes/no prompt. |<>|Immediately open a download. @@ -974,6 +1008,22 @@ How many steps to zoom out. === clear-keychain Clear the currently entered key chain. +[[click-element]] +=== click-element +Syntax: +:click-element [*--target* 'target'] 'filter' 'value'+ + +Click the element matching the given filter. + +The given filter needs to result in exactly one element, otherwise, an error is shown. + +==== positional arguments +* +'filter'+: How to filter the elements. id: Get an element based on its ID. + +* +'value'+: The value to filter for. + +==== optional arguments +* +*-t*+, +*--target*+: How to open the clicked element (normal/tab/tab-bg/window). + [[command-accept]] === command-accept Execute the command currently in the commandline. @@ -997,7 +1047,7 @@ Syntax: +:completion-item-focus 'which'+ Shift the focus of the completion menu to another item. ==== positional arguments -* +'which'+: 'next' or 'prev' +* +'which'+: 'next', 'prev', 'next-category', or 'prev-category'. [[drop-selection]] === drop-selection @@ -1169,10 +1219,6 @@ Open an external editor with the currently selected form field. The editor which should be launched can be configured via the `general -> editor` config option. -[[paste-primary]] -=== paste-primary -Paste the primary selection at cursor position. - [[prompt-accept]] === prompt-accept Accept the current prompt. @@ -1403,6 +1449,8 @@ These commands are mainly intended for debugging. They are hidden if qutebrowser |<>|Crash for debugging purposes. |<>|Dump the current page's content to a file. |<>|Change the number of log lines to be stored in RAM. +|<>|Change the log filter for console logging. +|<>|Change the log level for console logging. |<>|Evaluate a python string and display the results as a web page. |<>|Put data into the fake clipboard and enable logging, used for tests. |<>|Trace executed code via hunter. @@ -1454,6 +1502,24 @@ Change the number of log lines to be stored in RAM. ==== positional arguments * +'capacity'+: Number of lines for the log. +[[debug-log-filter]] +=== debug-log-filter +Syntax: +:debug-log-filter 'filters'+ + +Change the log filter for console logging. + +==== positional arguments +* +'filters'+: A comma separated list of logger names. + +[[debug-log-level]] +=== debug-log-level +Syntax: +:debug-log-level 'level'+ + +Change the log level for console logging. + +==== positional arguments +* +'level'+: The log level to set. + [[debug-pyeval]] === debug-pyeval Syntax: +:debug-pyeval [*--quiet*] 's'+ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 628a029e1..d121dd885 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -1,3 +1,7 @@ +// DO NOT EDIT THIS FILE DIRECTLY! +// It is autogenerated from docstrings by running: +// $ python3 scripts/dev/src2asciidoc.py + = Settings .Quick reference for section ``general'' @@ -19,6 +23,7 @@ |<>|Enable workarounds for broken sites. |<>|Default encoding to use for websites. |<>|How to open links in an existing instance if a new one is launched. +|<>|Which window to choose when opening links as new tabs. |<>|How to log javascript console messages. |<>|Whether to always save the open pages. |<>|The name of the session to save by default, or empty for the last loaded session. @@ -45,7 +50,6 @@ |<>|Whether to hide the statusbar unless a message is shown. |<>|Padding for statusbar (top, bottom, left, right). |<>|The format to use for the window title. The following placeholders are defined: -|<>|Whether to hide the mouse cursor. |<>|Use standard JavaScript modal dialog for alert() and confirm() |<>|Hide the window decoration when using wayland (requires restart) |<>|Keychains that shouldn't be shown in the keyhint dialog @@ -174,18 +178,18 @@ |============== |Setting|Description |<>|CSS border value for hints. -|<>|Opacity for hints. |<>|Mode to use for hints. |<>|Chars used for hint strings. |<>|Minimum number of chars used for hint strings. |<>|Whether to scatter hint key chains (like Vimium) or not (like dwb). Ignored for number hints. |<>|Make chars in hint strings uppercase. |<>|The dictionary file to be used by the word hints. -|<>|Follow a hint immediately when the hint text is completely matched. +|<>|Controls when a hint can be automatically followed without the user pressing Enter. |<>|A timeout (in milliseconds) to inhibit normal-mode key bindings after a successful auto-follow. |<>|A comma-separated list of regexes to use for 'next' links. |<>|A comma-separated list of regexes to use for 'prev' links. |<>|Which implementation to use to find elements to hint. +|<>|Controls hiding unmatched hints in rapid mode. |============== .Quick reference for section ``colors'' @@ -243,7 +247,7 @@ |<>|Color for the tab indicator on errors.. |<>|Color gradient interpolation system for the tab indicator. |<>|Font color for hints. -|<>|Background color for hints. +|<>|Background color for hints. Note that you can use a `rgba(...)` value for transparency. |<>|Font color for the matched part of hints. |<>|Background color for the download bar. |<>|Color gradient start for download text. @@ -443,6 +447,19 @@ Valid values: Default: +pass:[tab]+ +[[general-new-instance-open-target.window]] +=== new-instance-open-target.window +Which window to choose when opening links as new tabs. + +Valid values: + + * +first-opened+: Open new tabs in the first (oldest) opened window. + * +last-opened+: Open new tabs in the last (newest) opened window. + * +last-focused+: Open new tabs in the most recently focused window. + * +last-visible+: Open new tabs in the most recently visible window. + +Default: +pass:[last-focused]+ + [[general-log-javascript-console]] === log-javascript-console How to log javascript console messages. @@ -455,8 +472,6 @@ Valid values: Default: +pass:[debug]+ -This setting is only available with the QtWebKit backend. - [[general-save-session]] === save-session Whether to always save the open pages. @@ -586,7 +601,7 @@ This setting is only available with the QtWebKit backend. === user-stylesheet User stylesheet to use (absolute filename, filename relative to the config directory or CSS string). Will expand environment variables. -Default: +pass:[::-webkit-scrollbar { width: 0px; height: 0px; }]+ +Default: +pass:[html > ::-webkit-scrollbar { width: 0px; height: 0px; }]+ This setting is only available with the QtWebKit backend. @@ -646,17 +661,6 @@ The format to use for the window title. The following placeholders are defined: Default: +pass:[{perc}{title}{title_sep}qutebrowser]+ -[[ui-hide-mouse-cursor]] -=== hide-mouse-cursor -Whether to hide the mouse cursor. - -Valid values: - - * +true+ - * +false+ - -Default: +pass:[false]+ - [[ui-modal-js-dialog]] === modal-js-dialog Use standard JavaScript modal dialog for alert() and confirm() @@ -1596,12 +1600,6 @@ CSS border value for hints. Default: +pass:[1px solid #E3BE23]+ -[[hints-opacity]] -=== opacity -Opacity for hints. - -Default: +pass:[0.7]+ - [[hints-mode]] === mode Mode to use for hints. @@ -1656,14 +1654,16 @@ Default: +pass:[/usr/share/dict/words]+ [[hints-auto-follow]] === auto-follow -Follow a hint immediately when the hint text is completely matched. +Controls when a hint can be automatically followed without the user pressing Enter. Valid values: - * +true+ - * +false+ + * +always+: Auto-follow whenever there is only a single hint on a page. + * +unique-match+: Auto-follow whenever there is a unique non-empty match in either the hint string (word mode) or filter (number mode). + * +full-match+: Follow the hint when the user typed the whole hint (letter, word or number mode) or the element's text (only in number mode). + * +never+: The user will always need to press Enter to follow a hint. -Default: +pass:[true]+ +Default: +pass:[unique-match]+ [[hints-auto-follow-timeout]] === auto-follow-timeout @@ -1694,6 +1694,17 @@ Valid values: Default: +pass:[python]+ +[[hints-hide-unmatched-rapid-hints]] +=== hide-unmatched-rapid-hints +Controls hiding unmatched hints in rapid mode. + +Valid values: + + * +true+ + * +false+ + +Default: +pass:[true]+ + == searchengines Definitions of search engines which can be used via the address bar. The searchengine named `DEFAULT` is used when `general -> auto-search` is true and something else than a URL was entered to be opened. Other search engines can be used by prepending the search engine name to the search term, e.g. `:open google qutebrowser`. The string `{}` will be replaced by the search term, use `{{` and `}}` for literal `{`/`}` signs. @@ -2034,9 +2045,9 @@ Default: +pass:[black]+ [[colors-hints.bg]] === hints.bg -Background color for hints. +Background color for hints. Note that you can use a `rgba(...)` value for transparency. -Default: +pass:[-webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), color-stop(100%,#FFC542))]+ +Default: +pass:[qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 rgba(255, 247, 133, 0.8), stop:1 rgba(255, 197, 66, 0.8))]+ [[colors-hints.fg.match]] === hints.fg.match @@ -2183,7 +2194,7 @@ Default: +pass:[8pt ${_monospace}]+ === hints Font used for the hints. -Default: +pass:[bold 13px Monospace]+ +Default: +pass:[bold 13px ${_monospace}]+ [[fonts-debug-console]] === debug-console diff --git a/misc/docker/archlinux/Dockerfile b/misc/docker/archlinux/Dockerfile index 273bdb134..186dbd432 100644 --- a/misc/docker/archlinux/Dockerfile +++ b/misc/docker/archlinux/Dockerfile @@ -3,18 +3,15 @@ MAINTAINER Florian Bruhin RUN echo 'Server = http://mirror.de.leaseweb.net/archlinux/$repo/os/$arch' > /etc/pacman.d/mirrorlist RUN pacman-key --init && pacman-key --populate archlinux && pacman -Sy --noconfirm archlinux-keyring +RUN pacman -S --noconfirm pacman && pacman-db-upgrade -RUN pacman -Suyy --noconfirm -RUN pacman-db-upgrade - -RUN pacman -S --noconfirm \ +RUN pacman -Suyy --noconfirm \ git \ python-tox \ qt5-base \ qt5-webkit \ python-pyqt5 \ xorg-xinit \ - herbstluftwm \ xorg-server-xvfb RUN echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen && locale-gen @@ -23,13 +20,9 @@ RUN useradd user && mkdir /home/user && chown -R user:users /home/user USER user WORKDIR /home/user -ENV DISPLAY=:0 ENV LC_ALL=en_US.UTF-8 ENV LANG=en_US.UTF-8 -CMD Xvfb -screen 0 800x600x24 :0 & \ - sleep 2 && \ - herbstluftwm & \ - git clone /outside qutebrowser.git && \ +CMD git clone /outside qutebrowser.git && \ cd qutebrowser.git && \ tox -e py35 diff --git a/misc/docker/debian-jessie/Dockerfile b/misc/docker/debian-jessie/Dockerfile index 080346fbf..43acb6aef 100644 --- a/misc/docker/debian-jessie/Dockerfile +++ b/misc/docker/debian-jessie/Dockerfile @@ -11,10 +11,10 @@ RUN apt-get -y update && \ python-tox \ python3-sip \ xvfb \ + xauth \ git \ python3-setuptools \ wget \ - herbstluftwm \ locales \ libjs-pdf RUN echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen && locale-gen @@ -23,13 +23,9 @@ RUN useradd user && mkdir /home/user && chown -R user:users /home/user USER user WORKDIR /home/user -ENV DISPLAY=:0 ENV LC_ALL=en_US.UTF-8 ENV LANG=en_US.UTF-8 -CMD Xvfb -screen 0 800x600x24 :0 & \ - sleep 2 && \ - herbstluftwm & \ - git clone /outside qutebrowser.git && \ +CMD git clone /outside qutebrowser.git && \ cd qutebrowser.git && \ tox -e py34 diff --git a/misc/docker/ubuntu-xenial/Dockerfile b/misc/docker/ubuntu-xenial/Dockerfile index 0d012a87d..808f1d84d 100644 --- a/misc/docker/ubuntu-xenial/Dockerfile +++ b/misc/docker/ubuntu-xenial/Dockerfile @@ -14,7 +14,6 @@ RUN apt-get -y update && \ git \ python3-setuptools \ wget \ - herbstluftwm \ language-pack-en \ libjs-pdf \ dbus @@ -25,13 +24,9 @@ RUN useradd user && mkdir /home/user && chown -R user:users /home/user USER user WORKDIR /home/user -ENV DISPLAY=:0 ENV LC_ALL=en_US.UTF-8 ENV LANG=en_US.UTF-8 -CMD Xvfb -screen 0 800x600x24 :0 & \ - sleep 2 && \ - herbstluftwm & \ - git clone /outside qutebrowser.git && \ +CMD git clone /outside qutebrowser.git && \ cd qutebrowser.git && \ tox -e py35 diff --git a/misc/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt index a7620caaa..e06a19586 100644 --- a/misc/requirements/requirements-check-manifest.txt +++ b/misc/requirements/requirements-check-manifest.txt @@ -1,3 +1,3 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -check-manifest==0.31 +check-manifest==0.32 diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt index 9d634f092..b058b64d6 100644 --- a/misc/requirements/requirements-codecov.txt +++ b/misc/requirements/requirements-codecov.txt @@ -2,4 +2,4 @@ codecov==2.0.5 coverage==4.2 -requests==2.10.0 +requests==2.11.1 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index b4b0fee61..b0b098bc8 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -21,5 +21,5 @@ pep8-naming==0.4.1 pycodestyle==2.0.0 pydocstyle==1.0.0 pyflakes==1.2.3 -pyparsing==2.1.5 +pyparsing==2.1.8 six==1.10.0 diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index 647c6d169..c07d0fa64 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -1,2 +1,2 @@ pip==8.1.2 -setuptools==25.1.6 +setuptools==25.2.0 diff --git a/misc/requirements/requirements-pylint-master.txt b/misc/requirements/requirements-pylint-master.txt index 9e235a4fc..7a89bdf64 100644 --- a/misc/requirements/requirements-pylint-master.txt +++ b/misc/requirements/requirements-pylint-master.txt @@ -6,6 +6,6 @@ lazy-object-proxy==1.2.2 mccabe==0.5.2 -e git+https://github.com/PyCQA/pylint.git#egg=pylint ./scripts/dev/pylint_checkers -requests==2.10.0 +requests==2.11.1 six==1.10.0 wrapt==1.10.8 diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index 2fbf9dfb2..94d68f448 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -7,7 +7,7 @@ lazy-object-proxy==1.2.2 mccabe==0.5.2 pylint==1.6.4 ./scripts/dev/pylint_checkers -requests==2.10.0 +requests==2.11.1 six==1.10.0 -uritemplate.py==0.3.0 +uritemplate.py==1.0.0 wrapt==1.10.8 diff --git a/misc/requirements/requirements-tests-git.txt b/misc/requirements/requirements-tests-git.txt index 580bfc30b..b65ba5d6b 100644 --- a/misc/requirements/requirements-tests-git.txt +++ b/misc/requirements/requirements-tests-git.txt @@ -42,4 +42,8 @@ git+https://github.com/pallets/jinja.git git+https://github.com/pallets/markupsafe.git hg+http://bitbucket.org/birkenfeld/pygments-main hg+https://bitbucket.org/fdik/pypeg -hg+https://bitbucket.org/xi/pyyaml + +# Fails to build: +# gcc: error: ext/_yaml.c: No such file or directory +# hg+https://bitbucket.org/xi/pyyaml +PyYAML==3.11 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 703c8786e..c902a1d4d 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -2,11 +2,12 @@ beautifulsoup4==4.5.1 CherryPy==7.1.0 +click==6.6 coverage==4.2 decorator==4.0.10 -Flask==0.10.1 # rq.filter: < 0.11.0 +Flask==0.11.1 glob2==0.4.1 -httpbin==0.4.1 +httpbin==0.5.0 hypothesis==3.4.2 itsdangerous==0.24 # Jinja2==2.8 @@ -23,11 +24,11 @@ pytest-faulthandler==1.3.0 pytest-instafail==0.3.0 pytest-mock==1.2 pytest-qt==2.0.0 -pytest-repeat==0.3.0 -pytest-rerunfailures==2.0.0 +pytest-repeat==0.4.0 +pytest-rerunfailures==2.0.1 pytest-travis-fold==1.2.0 pytest-warnings==0.1.0 -pytest-xvfb==0.2.0 +pytest-xvfb==0.2.1 six==1.10.0 vulture==0.10 Werkzeug==0.11.10 diff --git a/misc/requirements/requirements-tests.txt-raw b/misc/requirements/requirements-tests.txt-raw index e9741bb9a..aaba28da6 100644 --- a/misc/requirements/requirements-tests.txt-raw +++ b/misc/requirements/requirements-tests.txt-raw @@ -1,7 +1,7 @@ beautifulsoup4 CherryPy coverage -Flask==0.10.1 +Flask httpbin hypothesis pytest @@ -19,5 +19,4 @@ pytest-warnings pytest-xvfb vulture -#@ filter: Flask < 0.11.0 #@ ignore: Jinja2, MarkupSafe diff --git a/pytest.ini b/pytest.ini index 04ce2b022..812063e54 100644 --- a/pytest.ini +++ b/pytest.ini @@ -15,6 +15,8 @@ markers = xfail_norun: xfail the test with out running it ci: Tests which should only run on CI. flaky_once: Try to rerun this test once if it fails + qtwebengine_todo: Features still missing with QtWebEngine + qtwebengine_skip: Tests not applicable with QtWebEngine qt_log_level_fail = WARNING qt_log_ignore = ^SpellCheck: .* @@ -35,4 +37,5 @@ qt_log_ignore = ^QObject::connect: Cannot connect \(null\)::stateChanged\(QNetworkSession::State\) to QNetworkReplyHttpImpl::_q_networkSessionStateChanged\(QNetworkSession::State\) ^QXcbClipboard: Cannot transfer data, no data available ^load glyph failed + ^Error when parsing the netrc file xfail_strict = true diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 295fca1fe..9d76b3fcb 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -33,9 +33,9 @@ import tokenize from PyQt5.QtWidgets import QApplication, QWidget from PyQt5.QtWebKit import QWebSettings -from PyQt5.QtGui import QDesktopServices, QPixmap, QIcon, QCursor, QWindow +from PyQt5.QtGui import QDesktopServices, QPixmap, QIcon, QWindow from PyQt5.QtCore import (pyqtSlot, qInstallMessageHandler, QTimer, QUrl, - QObject, Qt, QEvent, pyqtSignal) + QObject, QEvent, pyqtSignal) try: import hunter except ImportError: @@ -46,8 +46,8 @@ import qutebrowser.resources from qutebrowser.completion.models import instances as completionmodels from qutebrowser.commands import cmdutils, runners, cmdexc from qutebrowser.config import style, config, websettings, configexc -from qutebrowser.browser import urlmarks, adblock -from qutebrowser.browser.webkit import cookies, cache, history, downloads +from qutebrowser.browser import urlmarks, adblock, history +from qutebrowser.browser.webkit import cookies, cache, downloads from qutebrowser.browser.webkit.network import (qutescheme, proxy, networkmanager) from qutebrowser.mainwindow import mainwindow @@ -339,19 +339,20 @@ def _save_version(): def on_focus_changed(_old, new): """Register currently focused main window in the object registry.""" - if not isinstance(new, QWidget) and new is not None: + if new is None: + return + + if not isinstance(new, QWidget): log.misc.debug("on_focus_changed called with non-QWidget {!r}".format( new)) + return - if new is None or not isinstance(new, mainwindow.MainWindow): - try: - objreg.delete('last-focused-main-window') - except KeyError: - pass - qApp.restoreOverrideCursor() - else: - objreg.register('last-focused-main-window', new.window(), update=True) - _maybe_hide_mouse_cursor() + window = new.window() + if isinstance(window, mainwindow.MainWindow): + objreg.register('last-focused-main-window', window, update=True) + # A focused window must also be visible, and in this case we should + # consider it as the most recently looked-at window + objreg.register('last-visible-main-window', window, update=True) def open_desktopservices_url(url): @@ -362,17 +363,6 @@ def open_desktopservices_url(url): tabbed_browser.tabopen(url) -@config.change_filter('ui', 'hide-mouse-cursor', function=True) -def _maybe_hide_mouse_cursor(): - """Hide the mouse cursor if it isn't yet and it's configured.""" - if config.get('ui', 'hide-mouse-cursor'): - if qApp.overrideCursor() is not None: - return - qApp.setOverrideCursor(QCursor(Qt.BlankCursor)) - else: - qApp.restoreOverrideCursor() - - def _init_modules(args, crash_handler): """Initialize all 'modules' which need to be initialized. @@ -434,8 +424,6 @@ def _init_modules(args, crash_handler): os.environ['QT_WAYLAND_DISABLE_WINDOWDECORATION'] = '1' else: os.environ.pop('QT_WAYLAND_DISABLE_WINDOWDECORATION', None) - _maybe_hide_mouse_cursor() - objreg.get('config').changed.connect(_maybe_hide_mouse_cursor) temp_downloads = downloads.TempDownloadManager(qApp) objreg.register('temporary-downloads', temp_downloads) @@ -551,8 +539,9 @@ class Quitter: argdict['session'] = session argdict['override_restore'] = False # Ensure :restart works with --temp-basedir - argdict['temp_basedir'] = False - argdict['temp_basedir_restarted'] = True + if self._args.temp_basedir: + argdict['temp_basedir'] = False + argdict['temp_basedir_restarted'] = True # Dump the data data = json.dumps(argdict) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 662c88b5f..d218deebc 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -21,14 +21,16 @@ import itertools -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject, QPoint, QSizeF +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject, QSizeF from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QWidget +from PyQt5.QtWidgets import QWidget, QApplication from qutebrowser.keyinput import modeman from qutebrowser.config import config -from qutebrowser.utils import utils, objreg, usertypes, message, log, qtutils +from qutebrowser.utils import (utils, objreg, usertypes, message, log, qtutils, + urlutils) from qutebrowser.misc import miscwidgets +from qutebrowser.browser import mouse, hints tab_id_gen = itertools.count(0) @@ -67,14 +69,22 @@ class TabData: load. inspector: The QWebInspector used for this webview. viewing_source: Set if we're currently showing a source view. + open_target: How the next clicked link should be opened. + override_target: Override for open_target for fake clicks (like hints). """ - __slots__ = ['keep_icon', 'viewing_source', 'inspector'] - def __init__(self): self.keep_icon = False self.viewing_source = False self.inspector = None + self.open_target = usertypes.ClickTarget.normal + self.override_target = None + + def combined_target(self): + if self.override_target is not None: + return self.override_target + else: + return self.open_target class AbstractPrinting: @@ -219,19 +229,6 @@ class AbstractZoom(QObject): default_zoom = config.get('ui', 'default-zoom') self._set_factor_internal(float(default_zoom) / 100) - @pyqtSlot(QPoint) - def _on_mouse_wheel_zoom(self, delta): - """Handle zooming via mousewheel requested by the web view.""" - divider = config.get('input', 'mouse-zoom-divider') - factor = self.factor() + delta.y() / divider - if factor < 0: - return - perc = int(100 * factor) - message.info(self._win_id, "Zoom level: {}%".format(perc)) - self._neighborlist.fuzzyval = perc - self._set_factor_internal(factor) - self._default_zoom_changed = True - class AbstractCaret(QObject): @@ -418,6 +415,55 @@ class AbstractHistory: raise NotImplementedError +class AbstractElements: + + """Finding and handling of elements on the page.""" + + def __init__(self, tab): + self._widget = None + self._tab = tab + + def find_css(self, selector, callback, *, only_visible=False): + """Find all HTML elements matching a given selector async. + + Args: + callback: The callback to be called when the search finished. + selector: The CSS selector to search for. + only_visible: Only show elements which are visible on screen. + """ + raise NotImplementedError + + def find_id(self, elem_id, callback): + """Find the HTML element with the given ID async. + + Args: + callback: The callback to be called when the search finished. + elem_id: The ID to search for. + """ + raise NotImplementedError + + def find_focused(self, callback): + """Find the focused element on the page async. + + Args: + callback: The callback to be called when the search finished. + Called with a WebEngineElement or None. + """ + raise NotImplementedError + + def find_at_pos(self, pos, callback): + """Find the element at the given position async. + + This is also called "hit test" elsewhere. + + Args: + pos: The QPoint to get the element for. + callback: The callback to be called when the search finished. + Called with a WebEngineElement or None. + """ + raise NotImplementedError + + class AbstractTab(QWidget): """A wrapper over the given widget to hide its API and expose another one. @@ -455,6 +501,7 @@ class AbstractTab(QWidget): url_changed = pyqtSignal(QUrl) shutting_down = pyqtSignal() contents_size_changed = pyqtSignal(QSizeF) + add_history_item = pyqtSignal(QUrl, QUrl, str) # url, requested url, title def __init__(self, win_id, parent=None): self.win_id = win_id @@ -474,14 +521,23 @@ class AbstractTab(QWidget): # self.zoom = AbstractZoom(win_id=win_id) # self.search = AbstractSearch(parent=self) # self.printing = AbstractPrinting() + # self.elements = AbstractElements(self) + self.data = TabData() self._layout = miscwidgets.WrapperLayout(self) self._widget = None self._progress = 0 self._has_ssl_errors = False self._load_status = usertypes.LoadStatus.none + self._mouse_event_filter = mouse.MouseEventFilter(self, parent=self) self.backend = None + # FIXME:qtwebengine Should this be public api via self.hints? + # Also, should we get it out of objreg? + hintmanager = hints.HintManager(win_id, self.tab_id, parent=self) + objreg.register('hintmanager', hintmanager, scope='tab', + window=self.win_id, tab=self.tab_id) + def _set_widget(self, widget): # pylint: disable=protected-access self._widget = widget @@ -492,7 +548,11 @@ class AbstractTab(QWidget): self.zoom._widget = widget self.search._widget = widget self.printing._widget = widget - widget.mouse_wheel_zoom.connect(self.zoom._on_mouse_wheel_zoom) + self.elements._widget = widget + self._install_event_filter() + + def _install_event_filter(self): + raise NotImplementedError def _set_load_status(self, val): """Setter for load_status.""" @@ -502,6 +562,61 @@ class AbstractTab(QWidget): self._load_status = val self.load_status_changed.emit(val.name) + def _event_target(self): + """Return the widget events should be sent to.""" + raise NotImplementedError + + def send_event(self, evt, *, postpone=False): + """Send the given event to the underlying widget. + + Args: + postpone: Postpone the event to be handled later instead of + immediately. Using this might cause crashes in Qt. + """ + recipient = self._event_target() + if postpone: + QApplication.postEvent(recipient, evt) + else: + QApplication.sendEvent(recipient, evt) + + @pyqtSlot(QUrl) + def _on_link_clicked(self, url): + log.webview.debug("link clicked: url {}, override target {}, " + "open_target {}".format( + url.toDisplayString(), + self.data.override_target, + self.data.open_target)) + + if not url.isValid(): + msg = urlutils.get_errstring(url, "Invalid link clicked") + message.error(self.win_id, msg) + self.data.open_target = usertypes.ClickTarget.normal + return False + + target = self.data.combined_target() + + if target == usertypes.ClickTarget.normal: + return + elif target == usertypes.ClickTarget.tab: + win_id = self.win_id + bg_tab = False + elif target == usertypes.ClickTarget.tab_bg: + win_id = self.win_id + bg_tab = True + elif target == usertypes.ClickTarget.window: + from qutebrowser.mainwindow import mainwindow + window = mainwindow.MainWindow() + window.show() + win_id = window.win_id + bg_tab = False + else: + raise ValueError("Invalid ClickTarget {}".format(target)) + + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=win_id) + tabbed_browser.tabopen(url, background=bg_tab) + self.data.open_target = usertypes.ClickTarget.normal + @pyqtSlot(QUrl) def _on_url_changed(self, url): """Update title when URL has changed and no title is available.""" @@ -533,6 +648,11 @@ class AbstractTab(QWidget): if not self.title(): self.title_changed.emit(self.url().toDisplayString()) + @pyqtSlot() + def _on_history_trigger(self): + """Emit add_history_item when triggered by backend-specific signal.""" + raise NotImplementedError + @pyqtSlot(int) def _on_load_progress(self, perc): self._progress = perc @@ -542,7 +662,7 @@ class AbstractTab(QWidget): def _on_ssl_errors(self): self._has_ssl_errors = True - def url(self): + def url(self, requested=False): raise NotImplementedError def progress(self): @@ -583,14 +703,6 @@ class AbstractTab(QWidget): """ raise NotImplementedError - def run_js_blocking(self, code): - """Run javascript and block. - - This returns the result to the caller. Its use should be avoided when - possible as it runs a local event loop for QtWebEngine. - """ - raise NotImplementedError - def shutdown(self): raise NotImplementedError @@ -603,25 +715,6 @@ class AbstractTab(QWidget): def set_html(self, html, base_url): raise NotImplementedError - def find_all_elements(self, selector, callback, *, only_visible=False): - """Find all HTML elements matching a given selector async. - - Args: - callback: The callback to be called when the search finished. - selector: The CSS selector to search for. - only_visible: Only show elements which are visible on screen. - """ - raise NotImplementedError - - def find_focus_element(self, callback): - """Find the focused element on the page async. - - Args: - callback: The callback to be called when the search finished. - Called with a WebEngineElement or None. - """ - raise NotImplementedError - def __repr__(self): try: url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 41256c6f8..6b2bcc2ff 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -236,6 +236,8 @@ class CommandDispatcher: bg=False, tab=False, window=False, count=None): """Open a URL in the current/[count]th tab. + If the URL contains newlines, each line gets opened in its own tab. + Args: url: The URL to open. bg: Open in a new background tab. @@ -247,35 +249,73 @@ class CommandDispatcher: """ if url is None: if tab or bg or window: - url = config.get('general', 'default-page') + urls = [config.get('general', 'default-page')] else: raise cmdexc.CommandError("No URL given, but -t/-b/-w is not " "set!") else: - try: - url = objreg.get('quickmark-manager').get(url) - except urlmarks.Error: - try: - url = urlutils.fuzzy_url(url) - except urlutils.InvalidUrlError as e: - # We don't use cmdexc.CommandError here as this can be - # called async from edit_url - message.error(self._win_id, str(e)) - return - if tab or bg or window: - self._open(url, tab, bg, window, not implicit) - else: - curtab = self._cntwidget(count) - if curtab is None: - if count is None: - # We want to open a URL in the current tab, but none exists - # yet. - self._tabbed_browser.tabopen(url) - else: - # Explicit count with a tab that doesn't exist. - return + urls = self._parse_url_input(url) + for i, cur_url in enumerate(urls): + if not window and i > 0: + tab = False + bg = True + if tab or bg or window: + self._open(cur_url, tab, bg, window, not implicit) else: - curtab.openurl(url) + curtab = self._cntwidget(count) + if curtab is None: + if count is None: + # We want to open a URL in the current tab, but none + # exists yet. + self._tabbed_browser.tabopen(cur_url) + else: + # Explicit count with a tab that doesn't exist. + return + else: + curtab.openurl(cur_url) + + def _parse_url(self, url, *, force_search=False): + """Parse a URL or quickmark or search query. + + Args: + url: The URL to parse. + force_search: Whether to force a search even if the content can be + interpreted as a URL or a path. + + Return: + A URL that can be opened. + """ + try: + return objreg.get('quickmark-manager').get(url) + except urlmarks.Error: + try: + return urlutils.fuzzy_url(url, force_search=force_search) + except urlutils.InvalidUrlError as e: + # We don't use cmdexc.CommandError here as this can be + # called async from edit_url + message.error(self._win_id, str(e)) + return None + + def _parse_url_input(self, url): + """Parse a URL or newline-separated list of URLs. + + Args: + url: The URL or list to parse. + + Return: + A list of URLs that can be opened. + """ + force_search = False + urllist = [u for u in url.split('\n') if u.strip()] + if (len(urllist) > 1 and not urlutils.is_url(urllist[0]) and + urlutils.get_path_if_valid(urllist[0], check_exists=True) + is None): + urllist = [url] + force_search = True + for cur_url in urllist: + parsed = self._parse_url(cur_url, force_search=force_search) + if parsed is not None: + yield parsed @cmdutils.register(instance='command-dispatcher', name='reload', scope='window') @@ -384,10 +424,12 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') def tab_detach(self): """Detach the current tab to its own window.""" + if self._count() < 2: + raise cmdexc.CommandError("Cannot detach one tab.") url = self._current_url() self._open(url, window=True) cur_widget = self._current_widget() - self._tabbed_browser.close_tab(cur_widget) + self._tabbed_browser.close_tab(cur_widget, add_undo=False) def _back_forward(self, tab, bg, window, count, forward): """Helper function for :back/:forward.""" @@ -442,7 +484,8 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('where', choices=['prev', 'next', 'up', 'increment', 'decrement']) - def navigate(self, where: str, tab=False, bg=False, window=False): + @cmdutils.argument('count', count=True) + def navigate(self, where: str, tab=False, bg=False, window=False, count=1): """Open typical prev/next links or navigate using the URL path. This tries to automatically click on typical _Previous Page_ or @@ -462,6 +505,8 @@ class CommandDispatcher: tab: Open in a new tab. bg: Open in a background tab. window: Open in a new window. + count: For `increment` and `decrement`, the number to change the + URL by. For `up`, the number of levels to go up in the URL. """ # save the pre-jump position in the special ' mark self.set_mark("'") @@ -486,7 +531,7 @@ class CommandDispatcher: handler(browsertab=widget, win_id=self._win_id, baseurl=url, tab=tab, background=bg, window=window) elif where in ['up', 'increment', 'decrement']: - new_url = handlers[where](url) + new_url = handlers[where](url, count) self._open(new_url, tab, bg, window) else: # pragma: no cover raise ValueError("Got called with invalid value {} for " @@ -796,7 +841,8 @@ class CommandDispatcher: else: raise cmdexc.CommandError("Last tab") - @cmdutils.register(instance='command-dispatcher', scope='window') + @cmdutils.register(instance='command-dispatcher', scope='window', + deprecated="Use :open {clipboard}") def paste(self, sel=False, tab=False, bg=False, window=False): """Open a page from the clipboard. @@ -810,15 +856,12 @@ class CommandDispatcher: window: Open in new window. """ force_search = False - if sel and utils.supports_selection(): - target = "Primary selection" - else: + if not utils.supports_selection(): sel = False - target = "Clipboard" - text = utils.get_clipboard(selection=sel) - if not text.strip(): - raise cmdexc.CommandError("{} is empty.".format(target)) - log.misc.debug("{} contained: {!r}".format(target, text)) + try: + text = utils.get_clipboard(selection=sel) + except utils.ClipboardError as e: + raise cmdexc.CommandError(e) text_urls = [u for u in text.split('\n') if u.strip()] if (len(text_urls) > 1 and not urlutils.is_url(text_urls[0]) and urlutils.get_path_if_valid( @@ -1185,14 +1228,13 @@ class CommandDispatcher: current page's url. """ if url is None: - url = self._current_url().toString(QUrl.RemovePassword - | QUrl.FullyEncoded) + url = self._current_url().toString(QUrl.RemovePassword | + QUrl.FullyEncoded) try: objreg.get('bookmark-manager').delete(url) except KeyError: raise cmdexc.CommandError("Bookmark '{}' not found!".format(url)) - @cmdutils.register(instance='command-dispatcher', hide=True, scope='window') def follow_selected(self, *, tab=False): @@ -1314,7 +1356,11 @@ class CommandDispatcher: formatter = pygments.formatters.HtmlFormatter(full=True, linenos='table') highlighted = pygments.highlight(source, lexer, formatter) - current_url = self._current_url() + try: + current_url = self._current_url() + except cmdexc.CommandError as e: + message.error(self._win_id, str(e)) + return new_tab = self._tabbed_browser.tabopen(explicit=True) new_tab.set_html(highlighted, current_url) new_tab.data.viewing_source = True @@ -1424,8 +1470,7 @@ class CommandDispatcher: ed.edit(text) @cmdutils.register(instance='command-dispatcher', - modes=[KeyMode.insert], hide=True, scope='window', - backend=usertypes.Backend.QtWebKit) + modes=[KeyMode.insert], hide=True, scope='window') def open_editor(self): """Open an external editor with the currently selected form field. @@ -1433,7 +1478,7 @@ class CommandDispatcher: `general -> editor` config option. """ tab = self._current_widget() - tab.find_focus_element(self._open_editor_cb) + tab.elements.find_focused(self._open_editor_cb) def on_editing_finished(self, elem, text): """Write the editor text into the form field and clean up tempfile. @@ -1450,10 +1495,25 @@ class CommandDispatcher: raise cmdexc.CommandError("Element vanished while editing!") @cmdutils.register(instance='command-dispatcher', + deprecated="Use :insert-text {primary}", modes=[KeyMode.insert], hide=True, scope='window', needs_js=True, backend=usertypes.Backend.QtWebKit) def paste_primary(self): """Paste the primary selection at cursor position.""" + try: + self.insert_text(utils.get_clipboard(selection=True)) + except utils.SelectionUnsupportedError: + self.insert_text(utils.get_clipboard()) + + @cmdutils.register(instance='command-dispatcher', maxsplit=0, + scope='window', needs_js=True, + backend=usertypes.Backend.QtWebKit) + def insert_text(self, text): + """Insert text at cursor position. + + Args: + text: The text to insert. + """ # FIXME:qtwebengine have a proper API for this tab = self._current_widget() page = tab._widget.page() # pylint: disable=protected-access @@ -1463,20 +1523,57 @@ class CommandDispatcher: raise cmdexc.CommandError("No element focused!") if not elem.is_editable(strict=True): raise cmdexc.CommandError("Focused element is not editable!") - - try: - sel = utils.get_clipboard(selection=True) - except utils.SelectionUnsupportedError: - sel = utils.get_clipboard() - - log.misc.debug("Pasting primary selection into element {}".format( + log.misc.debug("Inserting text into element {}".format( elem.debug_text())) elem.run_js_async(""" - var sel = '{}'; + var text = '{}'; var event = document.createEvent('TextEvent'); - event.initTextEvent('textInput', true, true, null, sel); + event.initTextEvent('textInput', true, true, null, text); this.dispatchEvent(event); - """.format(javascript.string_escape(sel))) + """.format(javascript.string_escape(text))) + + @cmdutils.register(instance='command-dispatcher', scope='window', + hide=True) + @cmdutils.argument('filter_', choices=['id']) + def click_element(self, filter_: str, value, *, + target: usertypes.ClickTarget= + usertypes.ClickTarget.normal): + """Click the element matching the given filter. + + The given filter needs to result in exactly one element, otherwise, an + error is shown. + + Args: + filter_: How to filter the elements. + id: Get an element based on its ID. + value: The value to filter for. + target: How to open the clicked element (normal/tab/tab-bg/window). + """ + tab = self._current_widget() + + def single_cb(elem): + """Click a single element.""" + if elem is None: + message.error(self._win_id, "No element found!") + return + elem.click(target) + + # def multiple_cb(elems): + # """Click multiple elements (with only one expected).""" + # if not elems: + # message.error(self._win_id, "No element found!") + # return + # elif len(elems) != 1: + # message.error(self._win_id, "{} elements found!".format( + # len(elems))) + # return + # elems[0].click(target) + + handlers = { + 'id': (tab.elements.find_id, single_cb), + } + handler, callback = handlers[filter_] + handler(value, callback) def _search_cb(self, found, *, tab, old_scroll_pos, options, text, prev): """Callback called from search/search_next/search_prev. @@ -1853,20 +1950,20 @@ class CommandDispatcher: keyinfo.modifiers, keyinfo.text) if global_: - receiver = QApplication.focusWindow() - if receiver is None: + window = QApplication.focusWindow() + if window is None: raise cmdexc.CommandError("No focused window!") + QApplication.sendEvent(window, press_event) + QApplication.sendEvent(window, release_event) else: try: tab = objreg.get('tab', scope='tab', tab='current') except objreg.RegistryUnavailableError: raise cmdexc.CommandError("No focused webview!") - # pylint: disable=protected-access - receiver = tab._widget - # pylint: enable=protected-access - QApplication.postEvent(receiver, press_event) - QApplication.postEvent(receiver, release_event) + tab = self._current_widget() + tab.send_event(press_event) + tab.send_event(release_event) @cmdutils.register(instance='command-dispatcher', scope='window', debug=True) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 278ad7d0d..538f91928 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -23,23 +23,19 @@ import collections import functools import math import re +import html from string import ascii_lowercase -from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, - QTimer) -from PyQt5.QtGui import QMouseEvent -from PyQt5.QtWebKitWidgets import QWebPage +from PyQt5.QtCore import pyqtSlot, QObject, Qt, QUrl +from PyQt5.QtWidgets import QLabel -from qutebrowser.config import config +from qutebrowser.config import config, style from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.browser import webelem from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.utils import usertypes, log, qtutils, message, objreg, utils -ElemTuple = collections.namedtuple('ElemTuple', ['elem', 'label']) - - Target = usertypes.enum('Target', ['normal', 'current', 'tab', 'tab_fg', 'tab_bg', 'window', 'yank', 'yank_primary', 'run', 'fill', 'hover', 'download', @@ -57,15 +53,91 @@ def on_mode_entered(mode, win_id): modeman.maybe_leave(win_id, usertypes.KeyMode.hint, 'insert mode') +class HintLabel(QLabel): + + """A label for a link. + + Attributes: + elem: The element this label belongs to. + _context: The current hinting context. + """ + + STYLESHEET = """ + QLabel { + background-color: {{ color['hints.bg'] }}; + color: {{ color['hints.fg'] }}; + font: {{ font['hints'] }}; + border: {{ config.get('hints', 'border') }}; + padding-left: -3px; + padding-right: -3px; + } + """ + + def __init__(self, elem, context): + super().__init__(parent=context.tab) + self._context = context + self.elem = elem + + self.setAttribute(Qt.WA_StyledBackground, True) + style.set_register_stylesheet(self) + + self._context.tab.contents_size_changed.connect(self._move_to_elem) + self._move_to_elem() + self.show() + + def __repr__(self): + try: + text = self.text() + except RuntimeError: + text = '' + return utils.get_repr(self, elem=self.elem, text=text) + + def update_text(self, matched, unmatched): + """Set the text for the hint. + + Args: + matched: The part of the text which was typed. + unmatched: The part of the text which was not typed yet. + """ + if (config.get('hints', 'uppercase') and + self._context.hint_mode == 'letter'): + matched = html.escape(matched.upper()) + unmatched = html.escape(unmatched.upper()) + else: + matched = html.escape(matched) + unmatched = html.escape(unmatched) + + match_color = html.escape(config.get('colors', 'hints.fg.match')) + self.setText('{}{}'.format( + match_color, matched, unmatched)) + self.adjustSize() + + @pyqtSlot() + def _move_to_elem(self): + """Reposition the label to its element.""" + if not self.elem.has_frame(): + # This sometimes happens for some reason... + log.hints.debug("Frame for {!r} vanished!".format(self)) + self.hide() + return + no_js = config.get('hints', 'find-implementation') != 'javascript' + rect = self.elem.rect_on_view(no_js=no_js) + self.move(rect.x(), rect.y()) + + def cleanup(self): + """Clean up this element and hide it.""" + self.hide() + self.deleteLater() + + class HintContext: """Context namespace used for hinting. Attributes: - frames: The QWebFrames to use. - all_elems: A list of all (elem, label) namedtuples ever created. - elems: A mapping from key strings to (elem, label) namedtuples. - May contain less elements than `all_elems` due to filtering. + all_labels: A list of all HintLabel objects ever created. + labels: A mapping from key strings to HintLabel objects. + May contain less elements than `all_labels` due to filtering. baseurl: The URL of the current page. target: What to do with the opened links. normal/current/tab/tab_fg/tab_bg/window: Get passed to @@ -79,21 +151,23 @@ class HintContext: to_follow: The link to follow when enter is pressed. args: Custom arguments for userscript/spawn rapid: Whether to do rapid hinting. + filterstr: Used to save the filter string for restoring in rapid mode. tab: The WebTab object we started hinting in. group: The group of web elements to hint. """ def __init__(self): - self.all_elems = [] - self.elems = {} + self.all_labels = [] + self.labels = {} self.target = None self.baseurl = None self.to_follow = None self.rapid = False - self.frames = [] + self.filterstr = None self.args = [] self.tab = None self.group = None + self.hint_mode = None def get_args(self, urlstr): """Get the arguments, with {hint-url} replaced by the given URL.""" @@ -104,24 +178,11 @@ class HintContext: return args -class HintActions(QObject): +class HintActions: - """Actions which can be done after selecting a hint. + """Actions which can be done after selecting a hint.""" - Signals: - mouse_event: Mouse event to be posted in the web view. - arg: A QMouseEvent - start_hinting: Emitted when hinting starts, before a link is clicked. - arg: The ClickTarget to use. - stop_hinting: Emitted after a link was clicked. - """ - - mouse_event = pyqtSignal('QMouseEvent') - start_hinting = pyqtSignal(usertypes.ClickTarget) - stop_hinting = pyqtSignal() - - def __init__(self, win_id, parent=None): - super().__init__(parent) + def __init__(self, win_id): self._win_id = win_id def click(self, elem, context): @@ -144,54 +205,19 @@ class HintActions(QObject): else: target_mapping[Target.tab] = usertypes.ClickTarget.tab - # Click the center of the largest square fitting into the top/left - # corner of the rectangle, this will help if part of the element - # is hidden behind other elements - # https://github.com/The-Compiler/qutebrowser/issues/1005 - rect = elem.rect_on_view() - if rect.width() > rect.height(): - rect.setWidth(rect.height()) - else: - rect.setHeight(rect.width()) - pos = rect.center() - - action = "Hovering" if context.target == Target.hover else "Clicking" - log.hints.debug("{} on '{}' at position {}".format( - action, elem.debug_text(), pos)) - - self.start_hinting.emit(target_mapping[context.target]) - if context.target in [Target.tab, Target.tab_fg, Target.tab_bg, - Target.window]: - modifiers = Qt.ControlModifier - else: - modifiers = Qt.NoModifier - events = [ - QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton, - Qt.NoModifier), - ] - if context.target != Target.hover: - events += [ - QMouseEvent(QEvent.MouseButtonPress, pos, Qt.LeftButton, - Qt.LeftButton, modifiers), - QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton, - Qt.NoButton, modifiers), - ] - if context.target in [Target.normal, Target.current]: # Set the pre-jump mark ', so we can jump back here after following tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) tabbed_browser.set_mark("'") - if context.target == Target.current: + if context.target == Target.hover: + elem.hover() + elif context.target == Target.current: elem.remove_blank_target() - for evt in events: - self.mouse_event.emit(evt) - if elem.is_text_input() and elem.is_editable(): - QTimer.singleShot(0, functools.partial( - elem.frame().page().triggerAction, - QWebPage.MoveToEndOfDocument)) - QTimer.singleShot(0, self.stop_hinting.emit) + elem.click(target_mapping[context.target]) + else: + elem.click(target_mapping[context.target]) def yank(self, url, context): """Yank an element to the clipboard or primary selection. @@ -234,11 +260,8 @@ class HintActions(QObject): args = context.get_args(urlstr) text = ' '.join(args) if text[0] not in modeparsers.STARTCHARS: - message.error(self._win_id, - "Invalid command text '{}'.".format(text), - immediately=True) - else: - message.set_cmd_text(self._win_id, text) + raise HintingError("Invalid command text '{}'.".format(text)) + message.set_cmd_text(self._win_id, text) def download(self, elem, context): """Download a hint URL. @@ -249,16 +272,20 @@ class HintActions(QObject): """ url = elem.resolve_url(context.baseurl) if url is None: - raise HintingError + raise HintingError("No suitable link found for this element.") if context.rapid: prompt = False else: prompt = None + # FIXME:qtwebengine get a proper API for this + # pylint: disable=protected-access + page = elem._elem.webFrame().page() + # pylint: enable=protected-access + download_manager = objreg.get('download-manager', scope='window', window=self._win_id) - download_manager.get(url, page=elem.frame().page(), - prompt_download_directory=prompt) + download_manager.get(url, page=page, prompt_download_directory=prompt) def call_userscript(self, elem, context): """Call a userscript from a hint. @@ -282,7 +309,7 @@ class HintActions(QObject): userscripts.run_async(context.tab, cmd, *args, win_id=self._win_id, env=env) except userscripts.UnsupportedError as e: - message.error(self._win_id, str(e), immediately=True) + raise HintingError(str(e)) def spawn(self, url, context): """Spawn a simple command from a hint. @@ -308,7 +335,6 @@ class HintManager(QObject): _context: The HintContext for the current invocation. _win_id: The window ID this HintManager is associated with. _tab_id: The tab ID this HintManager is associated with. - _filterstr: Used to save the filter string for restoring in rapid mode. Signals: See HintActions @@ -331,23 +357,15 @@ class HintManager(QObject): Target.spawn: "Spawn command via hint", } - mouse_event = pyqtSignal('QMouseEvent') - start_hinting = pyqtSignal(usertypes.ClickTarget) - stop_hinting = pyqtSignal() - def __init__(self, win_id, tab_id, parent=None): """Constructor.""" super().__init__(parent) self._win_id = win_id self._tab_id = tab_id self._context = None - self._filterstr = None self._word_hinter = WordHinter() self._actions = HintActions(win_id) - self._actions.start_hinting.connect(self.start_hinting) - self._actions.stop_hinting.connect(self.stop_hinting) - self._actions.mouse_event.connect(self.mouse_event) mode_manager = objreg.get('mode-manager', scope='window', window=win_id) @@ -363,24 +381,14 @@ class HintManager(QObject): def _cleanup(self): """Clean up after hinting.""" - try: - self._context.tab.contents_size_changed.disconnect( - self.on_contents_size_changed) - except TypeError: - # For some reason, this can fail sometimes... - pass + for label in self._context.all_labels: + label.cleanup() - for elem in self._context.all_elems: - try: - elem.label.remove_from_document() - except webelem.Error: - pass text = self._get_text() message_bridge = objreg.get('message-bridge', scope='window', window=self._win_id) message_bridge.maybe_reset_text(text) self._context = None - self._filterstr = None def _hint_strings(self, elems): """Calculate the hint strings for elems. @@ -393,7 +401,9 @@ class HintManager(QObject): Return: A list of hint strings, in the same order as the elements. """ - hint_mode = config.get('hints', 'mode') + if not elems: + return [] + hint_mode = self._context.hint_mode if hint_mode == 'word': try: return self._word_hinter.hint(elems) @@ -513,92 +523,6 @@ class HintManager(QObject): hintstr.insert(0, chars[0]) return ''.join(hintstr) - def _is_hidden(self, elem): - """Check if the element is hidden via display=none.""" - display = elem.style_property('display', strategy='inline') - return display == 'none' - - def _show_elem(self, elem): - """Show a given element.""" - elem.set_style_property('display', 'inline !important') - - def _hide_elem(self, elem): - """Hide a given element.""" - elem.set_style_property('display', 'none !important') - - def _set_style_properties(self, elem, label): - """Set the hint CSS on the element given. - - Args: - elem: The QWebElement to set the style attributes for. - label: The label QWebElement. - """ - attrs = [ - ('display', 'inline !important'), - ('z-index', '{} !important'.format(int(2 ** 32 / 2 - 1))), - ('pointer-events', 'none !important'), - ('position', 'fixed !important'), - ('color', config.get('colors', 'hints.fg') + ' !important'), - ('background', config.get('colors', 'hints.bg') + ' !important'), - ('font', config.get('fonts', 'hints') + ' !important'), - ('border', config.get('hints', 'border') + ' !important'), - ('opacity', str(config.get('hints', 'opacity')) + ' !important'), - ] - - # Make text uppercase if set in config - if (config.get('hints', 'uppercase') and - config.get('hints', 'mode') == 'letter'): - attrs.append(('text-transform', 'uppercase !important')) - else: - attrs.append(('text-transform', 'none !important')) - - for k, v in attrs: - label.set_style_property(k, v) - self._set_style_position(elem, label) - - def _set_style_position(self, elem, label): - """Set the CSS position of the label element. - - Args: - elem: The QWebElement to set the style attributes for. - label: The label QWebElement. - """ - no_js = config.get('hints', 'find-implementation') != 'javascript' - rect = elem.rect_on_view(adjust_zoom=False, no_js=no_js) - left = rect.x() - top = rect.y() - log.hints.vdebug("Drawing label '{!r}' at {}/{} for element '{!r}' " - "(no_js: {})".format(label, left, top, elem, no_js)) - label.set_style_property('left', '{}px !important'.format(left)) - label.set_style_property('top', '{}px !important'.format(top)) - - def _draw_label(self, elem, string): - """Draw a hint label over an element. - - Args: - elem: The QWebElement to use. - string: The hint string to print. - - Return: - The newly created label element - """ - doc = elem.document_element() - body = doc.find_first('body') - if body is None: - parent = doc - else: - parent = body - label = parent.create_inside('span') - label['class'] = 'qutehint' - self._set_style_properties(elem, label) - label.set_text(string) - return label - - def _show_url_error(self): - """Show an error because no link was found.""" - message.error(self._win_id, "No suitable link found for this element.", - immediately=True) - def _check_args(self, target, *args): """Check the arguments passed to start() and raise if they're wrong. @@ -629,45 +553,57 @@ class HintManager(QObject): # Do multi-word matching return all(word in elemstr for word in filterstr.split()) + def _filter_matches_exactly(self, filterstr, elemstr): + """Return True if `filterstr` exactly matches `elemstr`.""" + # Empty string and None never match + if not filterstr: + return False + filterstr = filterstr.casefold() + elemstr = elemstr.casefold() + return filterstr == elemstr + def _start_cb(self, elems): """Initialize the elements and labels based on the context set.""" filterfunc = webelem.FILTERS.get(self._context.group, lambda e: True) elems = [e for e in elems if filterfunc(e)] if not elems: - raise cmdexc.CommandError("No elements found.") + message.error(self._win_id, "No elements found.", immediately=True) + return strings = self._hint_strings(elems) log.hints.debug("hints: {}".format(', '.join(strings))) - for e, string in zip(elems, strings): - label = self._draw_label(e, string) - elem = ElemTuple(e, label) - self._context.all_elems.append(elem) - self._context.elems[string] = elem + + for elem, string in zip(elems, strings): + label = HintLabel(elem, self._context) + label.update_text('', string) + self._context.all_labels.append(label) + self._context.labels[string] = label + keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) keyparser = keyparsers[usertypes.KeyMode.hint] keyparser.update_bindings(strings) - self._context.tab.contents_size_changed.connect( - self.on_contents_size_changed) message_bridge = objreg.get('message-bridge', scope='window', window=self._win_id) message_bridge.set_text(self._get_text()) modeman.enter(self._win_id, usertypes.KeyMode.hint, 'HintManager.start') + # to make auto-follow == 'always' work + self._handle_auto_follow() + @cmdutils.register(instance='hintmanager', scope='tab', name='hint', - star_args_optional=True, maxsplit=2, - backend=usertypes.Backend.QtWebKit) + star_args_optional=True, maxsplit=2) @cmdutils.argument('win_id', win_id=True) def start(self, rapid=False, group=webelem.Group.all, target=Target.normal, - *args, win_id): + *args, win_id, mode=None): """Start hinting. Args: rapid: Whether to do rapid hinting. This is only possible with targets `tab` (with background-tabs=true), `tab-bg`, `window`, `run`, `hover`, `userscript` and `spawn`. - group: The hinting mode to use. + group: The element types to hint. - `all`: All clickable elements. - `links`: Only links. @@ -694,6 +630,13 @@ class HintManager(QObject): link. - `spawn`: Spawn a command. + mode: The hinting mode to use. + + - `number`: Use numeric hints. + - `letter`: Use the chars in the hints->chars settings. + - `word`: Use hint words based on the html elements and the + extra words. + *args: Arguments for spawn/userscript/run/fill. - With `spawn`: The executable and arguments to spawn. @@ -714,6 +657,12 @@ class HintManager(QObject): tab = tabbed_browser.currentWidget() if tab is None: raise cmdexc.CommandError("No WebView available yet!") + if (tab.backend == usertypes.Backend.QtWebEngine and + target == Target.download): + message.error(self._win_id, "The download target is not available " + "yet with QtWebEngine.", immediately=True) + return + mode_manager = objreg.get('mode-manager', scope='window', window=self._win_id) if mode_manager.mode == usertypes.KeyMode.hint: @@ -732,93 +681,88 @@ class HintManager(QObject): raise cmdexc.CommandError("Rapid hinting makes no sense with " "target {}!".format(name)) + if mode is None: + mode = config.get('hints', 'mode') + self._check_args(target, *args) self._context = HintContext() self._context.tab = tab self._context.target = target self._context.rapid = rapid + self._context.hint_mode = mode try: self._context.baseurl = tabbed_browser.current_url() except qtutils.QtValueError: raise cmdexc.CommandError("No URL set for this page yet!") - self._context.tab = tab self._context.args = args self._context.group = group selector = webelem.SELECTORS[self._context.group] - self._context.tab.find_all_elements(selector, self._start_cb, + self._context.tab.elements.find_css(selector, self._start_cb, only_visible=True) + def current_mode(self): + """Return the currently active hinting mode (or None otherwise).""" + if self._context is None: + return None + + return self._context.hint_mode + + def _handle_auto_follow(self, keystr="", filterstr="", visible=None): + """Handle the auto-follow option.""" + if visible is None: + visible = {string: label + for string, label in self._context.labels.items() + if label.isVisible()} + + if len(visible) != 1: + return + + auto_follow = config.get('hints', 'auto-follow') + + if auto_follow == "always": + follow = True + elif auto_follow == "unique-match": + follow = keystr or filterstr + elif auto_follow == "full-match": + elemstr = str(list(visible.values())[0].elem) + filter_match = self._filter_matches_exactly(filterstr, elemstr) + follow = (keystr in visible) or filter_match + else: + follow = False + # save the keystr of the only one visible hint to be picked up + # later by self.follow_hint + self._context.to_follow = list(visible.keys())[0] + + if follow: + # apply auto-follow-timeout + timeout = config.get('hints', 'auto-follow-timeout') + keyparsers = objreg.get('keyparsers', scope='window', + window=self._win_id) + normal_parser = keyparsers[usertypes.KeyMode.normal] + normal_parser.set_inhibited_timeout(timeout) + # unpacking gets us the first (and only) key in the dict. + self._fire(*visible) + def handle_partial_key(self, keystr): """Handle a new partial keypress.""" log.hints.debug("Handling new keystring: '{}'".format(keystr)) - for string, elem in self._context.elems.items(): + for string, label in self._context.labels.items(): try: if string.startswith(keystr): matched = string[:len(keystr)] rest = string[len(keystr):] - match_color = config.get('colors', 'hints.fg.match') - elem.label.set_inner_xml( - '{}{}'.format( - match_color, matched, rest)) - if self._is_hidden(elem.label): - # hidden element which matches again -> show it - self._show_elem(elem.label) + label.update_text(matched, rest) + # Show label again if it was hidden before + label.show() else: - # element doesn't match anymore -> hide it - self._hide_elem(elem.label) + # element doesn't match anymore -> hide it, unless in rapid + # mode and hide-unmatched-rapid-hints is false (see #1799) + if (not self._context.rapid or + config.get('hints', 'hide-unmatched-rapid-hints')): + label.hide() except webelem.Error: pass - - def _filter_number_hints(self): - """Apply filters for numbered hints and renumber them. - - Return: - Elements which are still visible - """ - # renumber filtered hints - elems = [] - for e in self._context.all_elems: - try: - if not self._is_hidden(e.label): - elems.append(e) - except webelem.Error: - pass - if not elems: - # Whoops, filtered all hints - modeman.leave(self._win_id, usertypes.KeyMode.hint, - 'all filtered') - return {} - - strings = self._hint_strings(elems) - self._context.elems = {} - for elem, string in zip(elems, strings): - elem.label.set_inner_xml(string) - self._context.elems[string] = elem - keyparsers = objreg.get('keyparsers', scope='window', - window=self._win_id) - keyparser = keyparsers[usertypes.KeyMode.hint] - keyparser.update_bindings(strings, preserve_filter=True) - - return self._context.elems - - def _filter_non_number_hints(self): - """Apply filters for letter/word hints. - - Return: - Elements which are still visible - """ - visible = {} - for string, elem in self._context.elems.items(): - try: - if not self._is_hidden(elem.label): - visible[string] = elem - except webelem.Error: - pass - if not visible: - # Whoops, filtered all hints - modeman.leave(self._win_id, usertypes.KeyMode.hint, - 'all filtered') - return visible + self._handle_auto_follow(keystr=keystr) def filter_hints(self, filterstr): """Filter displayed hints according to a text. @@ -830,51 +774,54 @@ class HintManager(QObject): and `self._filterstr` are None, all hints are shown. """ if filterstr is None: - filterstr = self._filterstr + filterstr = self._context.filterstr else: - self._filterstr = filterstr + self._context.filterstr = filterstr - for elem in self._context.all_elems: + visible = [] + for label in self._context.all_labels: try: - if self._filter_matches(filterstr, str(elem.elem)): - if self._is_hidden(elem.label): - # hidden element which matches again -> show it - self._show_elem(elem.label) + if self._filter_matches(filterstr, str(label.elem)): + visible.append(label) + # Show label again if it was hidden before + label.show() else: # element doesn't match anymore -> hide it - self._hide_elem(elem.label) + label.hide() except webelem.Error: pass - if config.get('hints', 'mode') == 'number': - visible = self._filter_number_hints() - else: - visible = self._filter_non_number_hints() + if not visible: + # Whoops, filtered all hints + modeman.leave(self._win_id, usertypes.KeyMode.hint, + 'all filtered') + return - if (len(visible) == 1 and - config.get('hints', 'auto-follow') and - filterstr is not None): - # apply auto-follow-timeout - timeout = config.get('hints', 'auto-follow-timeout') + if self._context.hint_mode == 'number': + # renumber filtered hints + strings = self._hint_strings(visible) + self._context.labels = {} + for label, string in zip(visible, strings): + label.update_text('', string) + self._context.labels[string] = label keyparsers = objreg.get('keyparsers', scope='window', window=self._win_id) - normal_parser = keyparsers[usertypes.KeyMode.normal] - normal_parser.set_inhibited_timeout(timeout) - # unpacking gets us the first (and only) key in the dict. - self.fire(*visible) + keyparser = keyparsers[usertypes.KeyMode.hint] + keyparser.update_bindings(strings, preserve_filter=True) - def fire(self, keystr, force=False): + # Note: filter_hints can be called with non-None filterstr only + # when number mode is active + if filterstr is not None: + # pass self._context.labels as the dict of visible hints + self._handle_auto_follow(filterstr=filterstr, + visible=self._context.labels) + + def _fire(self, keystr): """Fire a completed hint. Args: keystr: The keychain string to follow. - force: When True, follow even when auto-follow is false. """ - if not (force or config.get('hints', 'auto-follow')): - self.handle_partial_key(keystr) - self._context.to_follow = keystr - return - # Handlers which take a QWebElement elem_handlers = { Target.normal: self._actions.click, @@ -896,9 +843,9 @@ class HintManager(QObject): Target.fill: self._actions.preset_cmd_text, Target.spawn: self._actions.spawn, } - elem = self._context.elems[keystr].elem + elem = self._context.labels[keystr].elem - if elem.frame() is None: + if not elem.has_frame(): message.error(self._win_id, "This element has no webframe.", immediately=True) @@ -910,7 +857,9 @@ class HintManager(QObject): elif self._context.target in url_handlers: url = elem.resolve_url(self._context.baseurl) if url is None: - self._show_url_error() + message.error(self._win_id, + "No suitable link found for this element.", + immediately=True) return handler = functools.partial(url_handlers[self._context.target], url, self._context) @@ -924,13 +873,13 @@ class HintManager(QObject): # Reset filtering self.filter_hints(None) # Undo keystring highlighting - for string, elem in self._context.elems.items(): - elem.label.set_inner_xml(string) + for string, label in self._context.labels.items(): + label.update_text('', string) try: handler() - except HintingError: - self._show_url_error() + except HintingError as e: + message.error(self._win_id, str(e), immediately=True) @cmdutils.register(instance='hintmanager', scope='tab', hide=True, modes=[usertypes.KeyMode.hint]) @@ -945,23 +894,9 @@ class HintManager(QObject): raise cmdexc.CommandError("No hint to follow") else: keystring = self._context.to_follow - elif keystring not in self._context.elems: + elif keystring not in self._context.labels: raise cmdexc.CommandError("No hint {}!".format(keystring)) - self.fire(keystring, force=True) - - @pyqtSlot() - def on_contents_size_changed(self): - """Reposition hints if contents size changed.""" - log.hints.debug("Contents size changed...!") - for e in self._context.all_elems: - try: - if e.elem.frame() is None: - # This sometimes happens for some reason... - e.label.remove_from_document() - continue - self._set_style_position(e.elem, e.label) - except webelem.Error: - pass + self._fire(keystring) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): @@ -1021,6 +956,7 @@ class WordHinter: "alt": lambda elem: elem["alt"], "name": lambda elem: elem["name"], "title": lambda elem: elem["title"], + "placeholder": lambda elem: elem["placeholder"], "src": lambda elem: elem["src"].split('/')[-1], "href": lambda elem: elem["href"].split('/')[-1], "text": str, @@ -1029,7 +965,9 @@ class WordHinter: extractable_attrs = collections.defaultdict(list, { "img": ["alt", "title", "src"], "a": ["title", "href", "text"], - "input": ["name"] + "input": ["name", "placeholder"], + "textarea": ["name", "placeholder"], + "button": ["text"] }) return (attr_extractors[attr](elem) diff --git a/qutebrowser/browser/webkit/history.py b/qutebrowser/browser/history.py similarity index 90% rename from qutebrowser/browser/webkit/history.py rename to qutebrowser/browser/history.py index 3ede42cc7..4e2e126f6 100644 --- a/qutebrowser/browser/webkit/history.py +++ b/qutebrowser/browser/history.py @@ -22,11 +22,11 @@ import time import collections -from PyQt5.QtCore import pyqtSignal, QUrl, QObject -from PyQt5.QtWebKit import QWebHistoryInterface +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, QObject from qutebrowser.commands import cmdutils -from qutebrowser.utils import utils, objreg, standarddir, log, qtutils +from qutebrowser.utils import (utils, objreg, standarddir, log, qtutils, + usertypes) from qutebrowser.config import config from qutebrowser.misc import lineparser @@ -108,34 +108,6 @@ class Entry: return cls(atime, url, title, redirect=redirect) -class WebHistoryInterface(QWebHistoryInterface): - - """Glue code between WebHistory and Qt's QWebHistoryInterface. - - Attributes: - _history: The WebHistory object. - """ - - def __init__(self, webhistory, parent=None): - super().__init__(parent) - self._history = webhistory - - def addHistoryEntry(self, url_string): - """Required for a QWebHistoryInterface impl, obsoleted by add_url.""" - pass - - def historyContains(self, url_string): - """Called by WebKit to determine if a URL is contained in the history. - - Args: - url_string: The URL (as string) to check for. - - Return: - True if the url is in the history, False otherwise. - """ - return url_string in self._history.history_dict - - class WebHistory(QObject): """The global history of visited pages. @@ -284,8 +256,19 @@ class WebHistory(QObject): self._saved_count = 0 self.cleared.emit() + @pyqtSlot(QUrl, QUrl, str) + def add_from_tab(self, url, requested_url, title): + """Add a new history entry as slot, called from a BrowserTab.""" + no_formatting = QUrl.UrlFormattingOption(0) + if (requested_url.isValid() and + not requested_url.matches(url, no_formatting)): + # If the url of the page is different than the url of the link + # originally clicked, save them both. + self.add_url(requested_url, title, redirect=True) + self.add_url(url, title) + def add_url(self, url, title="", *, redirect=False, atime=None): - """Called by WebKit when a URL should be added to the history. + """Called via add_from_tab when a URL should be added to the history. Args: url: A url (as QUrl) to add to the history. @@ -322,5 +305,7 @@ def init(parent=None): parent=parent) objreg.register('web-history', history) - interface = WebHistoryInterface(history, parent=history) - QWebHistoryInterface.setDefaultInterface(interface) + used_backend = usertypes.arg2backend[objreg.get('args').backend] + if used_backend == usertypes.Backend.QtWebKit: + from qutebrowser.browser.webkit import webkithistory + webkithistory.init(history) diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py index ed2e4ecd1..00225d091 100644 --- a/qutebrowser/browser/inspector.py +++ b/qutebrowser/browser/inspector.py @@ -22,7 +22,7 @@ import base64 import binascii -from PyQt5.QtWebKitWidgets import QWebInspector +from PyQt5.QtWidgets import QWidget from qutebrowser.utils import log, objreg from qutebrowser.misc import miscwidgets @@ -52,7 +52,7 @@ class WebInspectorError(Exception): pass -class AbstractWebInspector(QWebInspector): +class AbstractWebInspector(QWidget): """A customized WebInspector which stores its geometry.""" diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py new file mode 100644 index 000000000..a08b693eb --- /dev/null +++ b/qutebrowser/browser/mouse.py @@ -0,0 +1,227 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 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 . + +"""Mouse handling for a browser tab.""" + + +from qutebrowser.config import config +from qutebrowser.utils import message, log, usertypes +from qutebrowser.keyinput import modeman + + +from PyQt5.QtCore import QObject, QEvent, Qt, QTimer + + +class ChildEventFilter(QObject): + + """An event filter re-adding MouseEventFilter on ChildEvent. + + This is needed because QtWebEngine likes to randomly change its + focusProxy... + + FIXME:qtwebengine Add a test for this happening + + Attributes: + _filter: The event filter to install. + _widget: The widget expected to send out childEvents. + """ + + def __init__(self, eventfilter, widget, parent=None): + super().__init__(parent) + self._filter = eventfilter + assert widget is not None + self._widget = widget + + def eventFilter(self, obj, event): + """Act on ChildAdded events.""" + if event.type() == QEvent.ChildAdded: + child = event.child() + log.mouse.debug("{} got new child {}, installing filter".format( + obj, child)) + assert obj is self._widget + child.installEventFilter(self._filter) + return False + + +class MouseEventFilter(QObject): + + """Handle mouse events on a tab. + + Attributes: + _tab: The browsertab object this filter is installed on. + _handlers: A dict of handler functions for the handled events. + _ignore_wheel_event: Whether to ignore the next wheelEvent. + _check_insertmode_on_release: Whether an insertmode check should be + done when the mouse is released. + """ + + def __init__(self, tab, parent=None): + super().__init__(parent) + self._tab = tab + self._handlers = { + QEvent.MouseButtonPress: self._handle_mouse_press, + QEvent.MouseButtonRelease: self._handle_mouse_release, + QEvent.Wheel: self._handle_wheel, + QEvent.ContextMenu: self._handle_context_menu, + } + self._ignore_wheel_event = False + self._check_insertmode_on_release = False + + def _handle_mouse_press(self, e): + """Handle pressing of a mouse button.""" + is_rocker_gesture = (config.get('input', 'rocker-gestures') and + e.buttons() == Qt.LeftButton | Qt.RightButton) + + if e.button() in [Qt.XButton1, Qt.XButton2] or is_rocker_gesture: + self._mousepress_backforward(e) + return True + + self._ignore_wheel_event = True + self._mousepress_opentarget(e) + self._tab.elements.find_at_pos(e.pos(), self._mousepress_insertmode_cb) + + return False + + def _handle_mouse_release(self, _e): + """Handle releasing of a mouse button.""" + # We want to make sure we check the focus element after the WebView is + # updated completely. + QTimer.singleShot(0, self._mouserelease_insertmode) + return False + + def _handle_wheel(self, e): + """Zoom on Ctrl-Mousewheel. + + Args: + e: The QWheelEvent. + """ + if self._ignore_wheel_event: + # See https://github.com/The-Compiler/qutebrowser/issues/395 + self._ignore_wheel_event = False + return True + + if e.modifiers() & Qt.ControlModifier: + divider = config.get('input', 'mouse-zoom-divider') + factor = self._tab.zoom.factor() + (e.angleDelta().y() / divider) + if factor < 0: + return False + perc = int(100 * factor) + message.info(self._tab.win_id, "Zoom level: {}%".format(perc)) + self._tab.zoom.set_factor(factor) + + return False + + def _handle_context_menu(self, _e): + """Suppress context menus if rocker gestures are turned on.""" + return config.get('input', 'rocker-gestures') + + def _mousepress_insertmode_cb(self, elem): + """Check if the clicked element is editable.""" + if elem is None: + # Something didn't work out, let's find the focus element after + # a mouse release. + log.mouse.debug("Got None element, scheduling check on " + "mouse release") + self._check_insertmode_on_release = True + return + + if elem.is_editable(): + log.mouse.debug("Clicked editable element!") + modeman.enter(self._tab.win_id, usertypes.KeyMode.insert, + 'click', only_if_normal=True) + else: + log.mouse.debug("Clicked non-editable element!") + if config.get('input', 'auto-leave-insert-mode'): + modeman.maybe_leave(self._tab.win_id, + usertypes.KeyMode.insert, + 'click') + + def _mouserelease_insertmode(self): + """If we have an insertmode check scheduled, handle it.""" + if not self._check_insertmode_on_release: + return + self._check_insertmode_on_release = False + + def mouserelease_insertmode_cb(elem): + """Callback which gets called from JS.""" + if elem is None: + log.mouse.debug("Element vanished!") + return + + if elem.is_editable(): + log.mouse.debug("Clicked editable element (delayed)!") + modeman.enter(self._tab.win_id, usertypes.KeyMode.insert, + 'click-delayed', only_if_normal=True) + else: + log.mouse.debug("Clicked non-editable element (delayed)!") + if config.get('input', 'auto-leave-insert-mode'): + modeman.maybe_leave(self._tab.win_id, + usertypes.KeyMode.insert, + 'click-delayed') + + self._tab.elements.find_focused(mouserelease_insertmode_cb) + + def _mousepress_backforward(self, e): + """Handle back/forward mouse button presses. + + Args: + e: The QMouseEvent. + """ + if e.button() in [Qt.XButton1, Qt.LeftButton]: + # Back button on mice which have it, or rocker gesture + if self._tab.history.can_go_back(): + self._tab.history.back() + else: + message.error(self._tab.win_id, "At beginning of history.", + immediately=True) + elif e.button() in [Qt.XButton2, Qt.RightButton]: + # Forward button on mice which have it, or rocker gesture + if self._tab.history.can_go_forward(): + self._tab.history.forward() + else: + message.error(self._tab.win_id, "At end of history.", + immediately=True) + + def _mousepress_opentarget(self, e): + """Set the open target when something was clicked. + + Args: + e: The QMouseEvent. + """ + if e.button() == Qt.MidButton or e.modifiers() & Qt.ControlModifier: + background_tabs = config.get('tabs', 'background-tabs') + if e.modifiers() & Qt.ShiftModifier: + background_tabs = not background_tabs + if background_tabs: + target = usertypes.ClickTarget.tab_bg + else: + target = usertypes.ClickTarget.tab + self._tab.data.open_target = target + log.mouse.debug("Ctrl/Middle click, setting target: {}".format( + target)) + else: + self._tab.data.open_target = usertypes.ClickTarget.normal + log.mouse.debug("Normal click, setting normal target") + + def eventFilter(self, _obj, event): + """Filter events going to a QWeb(Engine)View.""" + evtype = event.type() + if evtype not in self._handlers: + return False + return self._handlers[evtype](event) diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py index 235cb0a7a..c78714315 100644 --- a/qutebrowser/browser/navigate.py +++ b/qutebrowser/browser/navigate.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Implementation of :navigate""" +"""Implementation of :navigate.""" import posixpath @@ -31,11 +31,12 @@ class Error(Exception): """Raised when the navigation can't be done.""" -def incdec(url, inc_or_dec): +def incdec(url, count, inc_or_dec): """Helper method for :navigate when `where' is increment/decrement. Args: url: The current url. + count: How much to increment or decrement by. inc_or_dec: Either 'increment' or 'decrement'. tab: Whether to open the link in a new tab. background: Open the link in a new background tab. @@ -43,23 +44,26 @@ def incdec(url, inc_or_dec): """ segments = set(config.get('general', 'url-incdec-segments')) try: - new_url = urlutils.incdec_number(url, inc_or_dec, segments=segments) + new_url = urlutils.incdec_number(url, inc_or_dec, count, + segments=segments) except urlutils.IncDecError as error: raise Error(error.msg) return new_url -def path_up(url): +def path_up(url, count): """Helper method for :navigate when `where' is up. Args: url: The current url. + count: The number of levels to go up in the url. """ path = url.path() if not path or path == '/': raise Error("Can't go up!") - new_path = posixpath.join(path, posixpath.pardir) - url.setPath(new_path) + for _i in range(0, min(count, path.count('/'))): + path = posixpath.join(path, posixpath.pardir) + url.setPath(path) return url @@ -137,4 +141,4 @@ def prevnext(*, browsertab, win_id, baseurl, prev=False, selector = ', '.join([webelem.SELECTORS[webelem.Group.links], webelem.SELECTORS[webelem.Group.prevnext]]) - browsertab.find_all_elements(selector, _prevnext_cb) + browsertab.elements.find_css(selector, _prevnext_cb) diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 749cb151f..0887bb745 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -29,7 +29,8 @@ Module attributes: import collections.abc -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, Qt, QEvent, QTimer +from PyQt5.QtGui import QMouseEvent from qutebrowser.config import config from qutebrowser.utils import log, usertypes, utils, qtutils @@ -73,7 +74,14 @@ class Error(Exception): class AbstractWebElement(collections.abc.MutableMapping): - """A wrapper around QtWebKit/QtWebEngine web element.""" + """A wrapper around QtWebKit/QtWebEngine web element. + + Attributes: + tab: The tab associated with this element. + """ + + def __init__(self, tab): + self._tab = tab def __eq__(self, other): raise NotImplementedError @@ -103,30 +111,14 @@ class AbstractWebElement(collections.abc.MutableMapping): html = None return utils.get_repr(self, html=html) - def frame(self): - """Get the main frame of this element.""" - # FIXME:qtwebengine get rid of this? + def has_frame(self): + """Check if this element has a valid frame attached.""" raise NotImplementedError def geometry(self): """Get the geometry for this element.""" raise NotImplementedError - def document_element(self): - """Get the document element of this element.""" - # FIXME:qtwebengine get rid of this? - raise NotImplementedError - - def create_inside(self, tagname): - """Append the given element inside the current one.""" - # FIXME:qtwebengine get rid of this? - raise NotImplementedError - - def find_first(self, selector): - """Find the first child based on the given CSS selector.""" - # FIXME:qtwebengine get rid of this? - raise NotImplementedError - def style_property(self, name, *, strategy): """Get the element style resolved with the given strategy.""" raise NotImplementedError @@ -166,21 +158,6 @@ class AbstractWebElement(collections.abc.MutableMapping): # FIXME:qtwebengine what to do about use_js with WebEngine? raise NotImplementedError - def set_inner_xml(self, xml): - """Set the given inner XML.""" - # FIXME:qtwebengine get rid of this? - raise NotImplementedError - - def remove_from_document(self): - """Remove the node from the document.""" - # FIXME:qtwebengine get rid of this? - raise NotImplementedError - - def set_style_property(self, name, value): - """Set the element style.""" - # FIXME:qtwebengine get rid of this? - raise NotImplementedError - def run_js_async(self, code, callback=None): """Run the given JS snippet async on the element.""" # FIXME:qtwebengine get rid of this? @@ -191,8 +168,7 @@ class AbstractWebElement(collections.abc.MutableMapping): # FIXME:qtwebengine get rid of this? raise NotImplementedError - def rect_on_view(self, *, elem_geometry=None, adjust_zoom=True, - no_js=False): + def rect_on_view(self, *, elem_geometry=None, no_js=False): """Get the geometry of the element relative to the webview. Uses the getClientRects() JavaScript method to obtain the collection of @@ -208,8 +184,6 @@ class AbstractWebElement(collections.abc.MutableMapping): elem_geometry: The geometry of the element, or None. Calling QWebElement::geometry is rather expensive so we want to avoid doing it twice. - adjust_zoom: Whether to adjust the element position based on the - current zoom level. no_js: Fall back to the Python implementation """ raise NotImplementedError @@ -367,3 +341,63 @@ class AbstractWebElement(collections.abc.MutableMapping): url = baseurl.resolved(url) qtutils.ensure_valid(url) return url + + def _mouse_pos(self): + """Get the position to click/hover.""" + # Click the center of the largest square fitting into the top/left + # corner of the rectangle, this will help if part of the element + # is hidden behind other elements + # https://github.com/The-Compiler/qutebrowser/issues/1005 + rect = self.rect_on_view() + if rect.width() > rect.height(): + rect.setWidth(rect.height()) + else: + rect.setHeight(rect.width()) + return rect.center() + + def click(self, click_target): + """Simulate a click on the element.""" + # FIXME:qtwebengine do we need this? + # self._widget.setFocus() + self._tab.data.override_target = click_target + + pos = self._mouse_pos() + + log.hints.debug("Sending fake click to '{}' at position {} with " + "target {}".format(self.debug_text(), pos, + click_target)) + + if click_target in [usertypes.ClickTarget.tab, + usertypes.ClickTarget.tab_bg, + usertypes.ClickTarget.window]: + modifiers = Qt.ControlModifier + else: + modifiers = Qt.NoModifier + + events = [ + QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton, + Qt.NoModifier), + QMouseEvent(QEvent.MouseButtonPress, pos, Qt.LeftButton, + Qt.LeftButton, modifiers), + QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton, + Qt.NoButton, modifiers), + ] + + for evt in events: + # For some reason, postpone=True is needed here to *not* cause + # segfaults in misc.feature because of :fake-key later... + self._tab.send_event(evt, postpone=True) + + def after_click(): + """Move cursor to end and reset override_target after clicking.""" + if self.is_text_input() and self.is_editable(): + self._tab.caret.move_to_end_of_document() + self._tab.data.override_target = None + QTimer.singleShot(0, after_click) + + def hover(self): + """Simulate a mouse hover over the element.""" + pos = self._mouse_pos() + event = QMouseEvent(QEvent.MouseMove, pos, Qt.NoButton, Qt.NoButton, + Qt.NoModifier) + self._tab.send_event(event) diff --git a/qutebrowser/browser/webengine/webengineelem.py b/qutebrowser/browser/webengine/webengineelem.py index 69de7c906..9610d3647 100644 --- a/qutebrowser/browser/webengine/webengineelem.py +++ b/qutebrowser/browser/webengine/webengineelem.py @@ -24,7 +24,7 @@ from PyQt5.QtCore import QRect -from qutebrowser.utils import log +from qutebrowser.utils import log, javascript from qutebrowser.browser import webelem @@ -32,7 +32,8 @@ class WebEngineElement(webelem.AbstractWebElement): """A web element for QtWebEngine, using JS under the hood.""" - def __init__(self, js_dict): + def __init__(self, js_dict, tab): + super().__init__(tab) self._id = js_dict['id'] self._js_dict = js_dict @@ -57,26 +58,13 @@ class WebEngineElement(webelem.AbstractWebElement): def __len__(self): return len(self._js_dict['attributes']) - def frame(self): - log.stub() - return None + def has_frame(self): + return True def geometry(self): log.stub() return QRect() - def document_element(self): - log.stub() - return None - - def create_inside(self, tagname): - log.stub() - return None - - def find_first(self, selector): - log.stub() - return None - def style_property(self, name, *, strategy): log.stub() return '' @@ -91,7 +79,7 @@ class WebEngineElement(webelem.AbstractWebElement): The returned name will always be lower-case. """ - return self._js_dict['tag_name'] + return self._js_dict['tag_name'].lower() def outer_xml(self): """Get the full HTML representation of this element.""" @@ -117,22 +105,8 @@ class WebEngineElement(webelem.AbstractWebElement): content-editable. """ # FIXME:qtwebengine what to do about use_js with WebEngine? - log.stub() - - def set_inner_xml(self, xml): - """Set the given inner XML.""" - # FIXME:qtwebengine get rid of this? - log.stub() - - def remove_from_document(self): - """Remove the node from the document.""" - # FIXME:qtwebengine get rid of this? - log.stub() - - def set_style_property(self, name, value): - """Set the element style.""" - # FIXME:qtwebengine get rid of this? - log.stub() + js_code = javascript.assemble('webelem', 'set_text', self._id, text) + self._tab.run_js_async(js_code) def run_js_async(self, code, callback=None): """Run the given JS snippet async on the element.""" @@ -145,15 +119,9 @@ class WebEngineElement(webelem.AbstractWebElement): log.stub() return None - def rect_on_view(self, *, elem_geometry=None, adjust_zoom=True, - no_js=False): + def rect_on_view(self, *, elem_geometry=None, no_js=False): """Get the geometry of the element relative to the webview. - Uses the getClientRects() JavaScript method to obtain the collection of - rectangles containing the element and returns the first rectangle which - is large enough (larger than 1px times 1px). If all rectangles returned - by getClientRects() are too small, falls back to elem.rect_on_view(). - Skipping of small rectangles is due to elements containing other elements with "display:block" style, see https://github.com/The-Compiler/qutebrowser/issues/1298 @@ -162,11 +130,35 @@ class WebEngineElement(webelem.AbstractWebElement): elem_geometry: The geometry of the element, or None. Calling QWebElement::geometry is rather expensive so we want to avoid doing it twice. - adjust_zoom: Whether to adjust the element position based on the - current zoom level. no_js: Fall back to the Python implementation """ - log.stub() + rects = self._js_dict['rects'] + for rect in rects: + # FIXME:qtwebengine + # width = rect.get("width", 0) + # height = rect.get("height", 0) + width = rect['width'] + height = rect['height'] + if width > 1 and height > 1: + # Fix coordinates according to zoom level + # We're not checking for zoom-text-only here as that doesn't + # exist for QtWebEngine. + zoom = self._tab.zoom.factor() + rect["left"] *= zoom + rect["top"] *= zoom + width *= zoom + height *= zoom + rect = QRect(rect["left"], rect["top"], width, height) + # FIXME:qtwebengine + # frame = self._elem.webFrame() + # while frame is not None: + # # Translate to parent frames' position (scroll position + # # is taken care of inside getClientRects) + # rect.translate(frame.geometry().topLeft()) + # frame = frame.parentFrame() + return rect + log.webview.debug("Couldn't find rectangle for {!r} ({})".format( + self, rects)) return QRect() def is_visible(self, mainframe): diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 2cde8fb1f..5c93bae49 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -36,22 +36,30 @@ from qutebrowser.utils import objreg, utils class Attribute(websettings.Attribute): + """A setting set via QWebEngineSettings::setAttribute.""" + GLOBAL_SETTINGS = QWebEngineSettings.globalSettings ENUM_BASE = QWebEngineSettings class Setter(websettings.Setter): + """A setting set via QWebEngineSettings getter/setter methods.""" + GLOBAL_SETTINGS = QWebEngineSettings.globalSettings class NullStringSetter(websettings.NullStringSetter): + """A setter for settings requiring a null QString as default.""" + GLOBAL_SETTINGS = QWebEngineSettings.globalSettings class StaticSetter(websettings.StaticSetter): + """A setting set via static QWebEngineSettings getter/setter methods.""" + GLOBAL_SETTINGS = QWebEngineSettings.globalSettings diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 7f43d352a..6ec26c3df 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -24,16 +24,15 @@ import functools -from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QPoint +from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QPoint, QUrl from PyQt5.QtGui import QKeyEvent, QIcon -from PyQt5.QtWidgets import QApplication # pylint: disable=no-name-in-module,import-error,useless-suppression from PyQt5.QtWebEngineWidgets import QWebEnginePage, QWebEngineScript # pylint: enable=no-name-in-module,import-error,useless-suppression -from qutebrowser.browser import browsertab +from qutebrowser.browser import browsertab, mouse from qutebrowser.browser.webengine import webview, webengineelem -from qutebrowser.utils import usertypes, qtutils, log, javascript +from qutebrowser.utils import usertypes, qtutils, log, javascript, utils class WebEnginePrinting(browsertab.AbstractPrinting): @@ -43,7 +42,7 @@ class WebEnginePrinting(browsertab.AbstractPrinting): def check_pdf_support(self): if not hasattr(self._widget.page(), 'printToPdf'): raise browsertab.WebTabError( - "Printing to PDF is unsupported with QtWebEngine on Qt > 5.7") + "Printing to PDF is unsupported with QtWebEngine on Qt < 5.7") def check_printer_support(self): raise browsertab.WebTabError( @@ -182,36 +181,35 @@ class WebEngineScroller(browsertab.AbstractScroller): """QtWebEngine implementations related to scrolling.""" + # FIXME:qtwebengine + # using stuff here with a big count/argument causes memory leaks and hangs + def __init__(self, tab, parent=None): super().__init__(tab, parent) - self._pos_perc = (None, None) + self._pos_perc = (0, 0) self._pos_px = QPoint() def _init_widget(self, widget): super()._init_widget(widget) page = widget.page() try: - page.scrollPositionChanged.connect( - self._on_scroll_pos_changed) + page.scrollPositionChanged.connect(self._update_pos) except AttributeError: log.stub('scrollPositionChanged, on Qt < 5.7') - self._on_scroll_pos_changed() + self._pos_perc = (None, None) def _key_press(self, key, count=1): # FIXME:qtwebengine Abort scrolling if the minimum/maximum was reached. press_evt = QKeyEvent(QEvent.KeyPress, key, Qt.NoModifier, 0, 0, 0) release_evt = QKeyEvent(QEvent.KeyRelease, key, Qt.NoModifier, 0, 0, 0) - recipient = self._widget.focusProxy() for _ in range(count): - # If we get a segfault here, we might want to try sendEvent - # instead. - QApplication.postEvent(recipient, press_evt) - QApplication.postEvent(recipient, release_evt) + self._tab.send_event(press_evt) + self._tab.send_event(release_evt) @pyqtSlot() - def _on_scroll_pos_changed(self): + def _update_pos(self): """Update the scroll position attributes when it changed.""" - def update_scroll_pos(jsret): + def update_pos_cb(jsret): """Callback after getting scroll position via JS.""" if jsret is None: # This can happen when the callback would get called after @@ -222,8 +220,8 @@ class WebEngineScroller(browsertab.AbstractScroller): self._pos_px = QPoint(jsret['px']['x'], jsret['px']['y']) self.perc_changed.emit(*self._pos_perc) - js_code = javascript.assemble('scroll', 'scroll_pos') - self._tab.run_js_async(js_code, update_scroll_pos) + js_code = javascript.assemble('scroll', 'pos') + self._tab.run_js_async(js_code, update_pos_cb) def pos_px(self): return self._pos_px @@ -232,18 +230,18 @@ class WebEngineScroller(browsertab.AbstractScroller): return self._pos_perc def to_perc(self, x=None, y=None): - js_code = javascript.assemble('scroll', 'scroll_to_perc', x, y) + js_code = javascript.assemble('scroll', 'to_perc', x, y) self._tab.run_js_async(js_code) def to_point(self, point): - self._tab.run_js_async("window.scroll({x}, {y});".format( - x=point.x(), y=point.y())) + js_code = javascript.assemble('window', 'scroll', point.x(), point.y()) + self._tab.run_js_async(js_code) def delta(self, x=0, y=0): - self._tab.run_js_async("window.scrollBy({x}, {y});".format(x=x, y=y)) + self._tab.run_js_async(javascript.assemble('window', 'scrollBy', x, y)) def delta_page(self, x=0, y=0): - js_code = javascript.assemble('scroll', 'scroll_delta_page', x, y) + js_code = javascript.assemble('scroll', 'delta_page', x, y) self._tab.run_js_async(js_code) def up(self, count=1): @@ -317,13 +315,70 @@ class WebEngineZoom(browsertab.AbstractZoom): return self._widget.zoomFactor() +class WebEngineElements(browsertab.AbstractElements): + + """QtWebEngine implemementations related to elements on the page.""" + + def _js_cb_multiple(self, callback, js_elems): + """Handle found elements coming from JS and call the real callback. + + Args: + callback: The callback to call with the found elements. + js_elems: The elements serialized from javascript. + """ + elems = [] + for js_elem in js_elems: + elem = webengineelem.WebEngineElement(js_elem, tab=self._tab) + elems.append(elem) + callback(elems) + + def _js_cb_single(self, callback, js_elem): + """Handle a found focus elem coming from JS and call the real callback. + + Args: + callback: The callback to call with the found element. + Called with a WebEngineElement or None. + js_elem: The element serialized from javascript. + """ + log.webview.debug("Got element from JS: {!r}".format(js_elem)) + if js_elem is None: + callback(None) + else: + elem = webengineelem.WebEngineElement(js_elem, tab=self._tab) + callback(elem) + + def find_css(self, selector, callback, *, only_visible=False): + js_code = javascript.assemble('webelem', 'find_all', selector, + only_visible) + js_cb = functools.partial(self._js_cb_multiple, callback) + self._tab.run_js_async(js_code, js_cb) + + def find_id(self, elem_id, callback): + js_code = javascript.assemble('webelem', 'element_by_id', elem_id) + js_cb = functools.partial(self._js_cb_single, callback) + self._tab.run_js_async(js_code, js_cb) + + def find_focused(self, callback): + js_code = javascript.assemble('webelem', 'focus_element') + js_cb = functools.partial(self._js_cb_single, callback) + self._tab.run_js_async(js_code, js_cb) + + def find_at_pos(self, pos, callback): + assert pos.x() >= 0 + assert pos.y() >= 0 + js_code = javascript.assemble('webelem', 'element_at_pos', + pos.x(), pos.y()) + js_cb = functools.partial(self._js_cb_single, callback) + self._tab.run_js_async(js_code, js_cb) + + class WebEngineTab(browsertab.AbstractTab): """A QtWebEngine tab in the browser.""" def __init__(self, win_id, mode_manager, parent=None): super().__init__(win_id) - widget = webview.WebEngineView() + widget = webview.WebEngineView(tabdata=self.data) self.history = WebEngineHistory(self) self.scroller = WebEngineScroller(self, parent=self) self.caret = WebEngineCaret(win_id=win_id, mode_manager=mode_manager, @@ -331,16 +386,54 @@ class WebEngineTab(browsertab.AbstractTab): self.zoom = WebEngineZoom(win_id=win_id, parent=self) self.search = WebEngineSearch(parent=self) self.printing = WebEnginePrinting() + self.elements = WebEngineElements(self) self._set_widget(widget) self._connect_signals() self.backend = usertypes.Backend.QtWebEngine + # init js stuff + self._init_js() + self._child_event_filter = None + + def _init_js(self): + js_code = '\n'.join([ + '"use strict";', + 'window._qutebrowser = {};', + utils.read_file('javascript/scroll.js'), + utils.read_file('javascript/webelem.js'), + ]) + script = QWebEngineScript() + script.setInjectionPoint(QWebEngineScript.DocumentCreation) + page = self._widget.page() + script.setSourceCode(js_code) + + try: + page.runJavaScript("", QWebEngineScript.ApplicationWorld) + except TypeError: + # We're unable to pass a world to runJavaScript + script.setWorldId(QWebEngineScript.MainWorld) + else: + script.setWorldId(QWebEngineScript.ApplicationWorld) + + # FIXME:qtwebengine what about runsOnSubFrames? + page.scripts().insert(script) + + def _install_event_filter(self): + self._widget.focusProxy().installEventFilter(self._mouse_event_filter) + self._child_event_filter = mouse.ChildEventFilter( + eventfilter=self._mouse_event_filter, widget=self._widget, + parent=self) + self._widget.installEventFilter(self._child_event_filter) def openurl(self, url): self._openurl_prepare(url) self._widget.load(url) - def url(self): - return self._widget.url() + def url(self, requested=False): + page = self._widget.page() + if requested: + return page.requestedUrl() + else: + return page.url() def dump_async(self, callback, *, plain=False): if plain: @@ -362,23 +455,6 @@ class WebEngineTab(browsertab.AbstractTab): else: self._widget.page().runJavaScript(code, callback) - def run_js_blocking(self, code): - unset = object() - loop = qtutils.EventLoop() - js_ret = unset - - def js_cb(val): - """Handle return value from JS and stop blocking.""" - nonlocal js_ret - js_ret = val - loop.quit() - - self.run_js_async(code, js_cb) - loop.exec_() # blocks until loop.quit() in js_cb - assert js_ret is not unset - - return js_ret - def shutdown(self): log.stub() @@ -413,42 +489,19 @@ class WebEngineTab(browsertab.AbstractTab): def clear_ssl_errors(self): log.stub() - def _find_all_elements_js_cb(self, callback, js_elems): - """Handle found elements coming from JS and call the real callback. + @pyqtSlot() + def _on_history_trigger(self): + url = self.url() + requested_url = self.url(requested=True) - Args: - callback: The callback originally passed to find_all_elements. - js_elems: The elements serialized from javascript. - """ - elems = [] - for js_elem in js_elems: - elem = webengineelem.WebEngineElement(js_elem) - elems.append(elem) - callback(elems) + # Don't save the title if it's generated from the URL + title = self.title() + title_url = QUrl(url) + title_url.setScheme('') + if title == title_url.toDisplayString(QUrl.RemoveScheme).strip('/'): + title = "" - def find_all_elements(self, selector, callback, *, only_visible=False): - js_code = javascript.assemble('webelem', 'find_all_elements', selector) - js_cb = functools.partial(self._find_all_elements_js_cb, callback) - self.run_js_async(js_code, js_cb) - - def _find_focus_element_js_cb(self, callback, js_elem): - """Handle a found focus elem coming from JS and call the real callback. - - Args: - callback: The callback originally passed to find_focus_element. - Called with a WebEngineElement or None. - js_elem: The element serialized from javascript. - """ - log.webview.debug("Got focus element from JS: {!r}".format(js_elem)) - if js_elem is None: - callback(None) - else: - callback(webengineelem.WebEngineElement(js_elem)) - - def find_focus_element(self, callback): - js_code = javascript.assemble('webelem', 'focus_element') - js_cb = functools.partial(self._find_focus_element_js_cb, callback) - self.run_js_async(js_code, js_cb) + self.add_history_item.emit(url, requested_url, title) def _connect_signals(self): view = self._widget @@ -457,10 +510,12 @@ class WebEngineTab(browsertab.AbstractTab): page.linkHovered.connect(self.link_hovered) page.loadProgress.connect(self._on_load_progress) page.loadStarted.connect(self._on_load_started) + page.loadFinished.connect(self._on_history_trigger) view.titleChanged.connect(self.title_changed) view.urlChanged.connect(self._on_url_changed) page.loadFinished.connect(self._on_load_finished) page.certificate_error.connect(self._on_ssl_errors) + page.link_clicked.connect(self._on_link_clicked) try: view.iconChanged.connect(self.icon_changed) except AttributeError: @@ -469,3 +524,6 @@ class WebEngineTab(browsertab.AbstractTab): page.contentsSizeChanged.connect(self.contents_size_changed) except AttributeError: log.stub('contentsSizeChanged, on Qt < 5.7') + + def _event_target(self): + return self._widget.focusProxy() diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index a4c33b6cb..451a493a0 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -20,43 +20,39 @@ """The main browser widget for QtWebEngine.""" -from PyQt5.QtCore import pyqtSignal, Qt, QPoint +from PyQt5.QtCore import pyqtSignal, QUrl # pylint: disable=no-name-in-module,import-error,useless-suppression from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage # pylint: enable=no-name-in-module,import-error,useless-suppression from qutebrowser.config import config -from qutebrowser.utils import log +from qutebrowser.utils import log, debug, usertypes class WebEngineView(QWebEngineView): """Custom QWebEngineView subclass with qutebrowser-specific features.""" - mouse_wheel_zoom = pyqtSignal(QPoint) - - def __init__(self, parent=None): + def __init__(self, tabdata, parent=None): super().__init__(parent) - self.setPage(WebEnginePage(self)) - - def wheelEvent(self, e): - """Zoom on Ctrl-Mousewheel. - - Args: - e: The QWheelEvent. - """ - if e.modifiers() & Qt.ControlModifier: - e.accept() - self.mouse_wheel_zoom.emit(e.angleDelta()) - else: - super().wheelEvent(e) + self.setPage(WebEnginePage(tabdata, parent=self)) class WebEnginePage(QWebEnginePage): - """Custom QWebEnginePage subclass with qutebrowser-specific features.""" + """Custom QWebEnginePage subclass with qutebrowser-specific features. + + Signals: + certificate_error: FIXME:qtwebengine + link_clicked: Emitted when a link was clicked on a page. + """ certificate_error = pyqtSignal() + link_clicked = pyqtSignal(QUrl) + + def __init__(self, tabdata, parent=None): + super().__init__(parent) + self._tabdata = tabdata def certificateError(self, error): self.certificate_error.emit() @@ -82,3 +78,31 @@ class WebEnginePage(QWebEnginePage): """Handle new windows via JS.""" log.stub() return None + + def acceptNavigationRequest(self, + url: QUrl, + typ: QWebEnginePage.NavigationType, + is_main_frame: bool): + """Override acceptNavigationRequest to handle clicked links. + + Setting linkDelegationPolicy to DelegateAllLinks and using a slot bound + to linkClicked won't work correctly, because when in a frameset, we + have no idea in which frame the link should be opened. + + Checks if it should open it in a tab (middle-click or control) or not, + and then conditionally opens the URL. Opening it in a new tab/window + is handled in the slot connected to link_clicked. + """ + target = self._tabdata.combined_target() + log.webview.debug("navigation request: url {}, type {}, " + "target {}, is_main_frame {}".format( + url.toDisplayString(), + debug.qenum_key(QWebEnginePage, typ), + target, is_main_frame)) + + if typ != QWebEnginePage.NavigationTypeLinkClicked: + return True + + self.link_clicked.emit(url) + + return url.isValid() and target == usertypes.ClickTarget.normal diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index 2928c5a3f..c615fcad4 100644 --- a/qutebrowser/browser/webkit/mhtml.py +++ b/qutebrowser/browser/webkit/mhtml.py @@ -271,7 +271,7 @@ class _Downloader: elements = web_frame.findAllElements('link, script, img') for element in elements: - element = webkitelem.WebKitElement(element) + element = webkitelem.WebKitElement(element, tab=self.tab) # Websites are free to set whatever rel=... attribute they want. # We just care about stylesheets and icons. if not _check_rel(element): @@ -288,7 +288,7 @@ class _Downloader: styles = web_frame.findAllElements('style') for style in styles: - style = webkitelem.WebKitElement(style) + style = webkitelem.WebKitElement(style, tab=self.tab) # The Mozilla Developer Network says: # type: This attribute defines the styling language as a MIME type # (charset should not be specified). This attribute is optional and @@ -301,7 +301,7 @@ class _Downloader: # Search for references in inline styles for element in web_frame.findAllElements('[style]'): - element = webkitelem.WebKitElement(element) + element = webkitelem.WebKitElement(element, tab=self.tab) style = element['style'] for element_url in _get_css_imports(style, inline=True): self._fetch_url(web_url.resolved(QUrl(element_url))) diff --git a/qutebrowser/browser/webkit/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py index 895c48e59..67c394e48 100644 --- a/qutebrowser/browser/webkit/webkitelem.py +++ b/qutebrowser/browser/webkit/webkitelem.py @@ -38,7 +38,8 @@ class WebKitElement(webelem.AbstractWebElement): """A wrapper around a QWebElement.""" - def __init__(self, elem): + def __init__(self, elem, tab): + super().__init__(tab) if isinstance(elem, self.__class__): raise TypeError("Trying to wrap a wrapper!") if elem.isNull(): @@ -83,36 +84,14 @@ class WebKitElement(webelem.AbstractWebElement): if self._elem.isNull(): raise IsNullError('Element {} vanished!'.format(self._elem)) - def frame(self): + def has_frame(self): self._check_vanished() - return self._elem.webFrame() + return self._elem.webFrame() is not None def geometry(self): self._check_vanished() return self._elem.geometry() - def document_element(self): - self._check_vanished() - elem = self._elem.webFrame().documentElement() - return WebKitElement(elem) - - def create_inside(self, tagname): - # It seems impossible to create an empty QWebElement for which isNull() - # is false so we can work with it. - # As a workaround, we use appendInside() with markup as argument, and - # then use lastChild() to get a reference to it. - # See: http://stackoverflow.com/q/7364852/2085149 - self._check_vanished() - self._elem.appendInside('<{}>'.format(tagname, tagname)) - return WebKitElement(self._elem.lastChild()) - - def find_first(self, selector): - self._check_vanished() - elem = self._elem.findFirst(selector) - if elem.isNull(): - return None - return WebKitElement(elem) - def style_property(self, name, *, strategy): self._check_vanished() strategies = { @@ -156,18 +135,6 @@ class WebKitElement(webelem.AbstractWebElement): text = javascript.string_escape(text) self._elem.evaluateJavaScript("this.value='{}'".format(text)) - def set_inner_xml(self, xml): - self._check_vanished() - self._elem.setInnerXml(xml) - - def remove_from_document(self): - self._check_vanished() - self._elem.removeFromDocument() - - def set_style_property(self, name, value): - self._check_vanished() - return self._elem.setStyleProperty(name, value) - def run_js_async(self, code, callback=None): """Run the given JS snippet async on the element.""" self._check_vanished() @@ -180,9 +147,9 @@ class WebKitElement(webelem.AbstractWebElement): elem = self._elem.parent() if elem is None: return None - return WebKitElement(elem) + return WebKitElement(elem, tab=self._tab) - def _rect_on_view_js(self, adjust_zoom): + def _rect_on_view_js(self): """Javascript implementation for rect_on_view.""" # FIXME:qtwebengine maybe we can reuse this? rects = self._elem.evaluateJavaScript("this.getClientRects()") @@ -203,7 +170,7 @@ class WebKitElement(webelem.AbstractWebElement): if width > 1 and height > 1: # fix coordinates according to zoom level zoom = self._elem.webFrame().zoomFactor() - if not config.get('ui', 'zoom-text-only') and adjust_zoom: + if not config.get('ui', 'zoom-text-only'): rect["left"] *= zoom rect["top"] *= zoom width *= zoom @@ -231,18 +198,9 @@ class WebKitElement(webelem.AbstractWebElement): rect.translate(frame.geometry().topLeft()) rect.translate(frame.scrollPosition() * -1) frame = frame.parentFrame() - # We deliberately always adjust the zoom here, even with - # adjust_zoom=False - if elem_geometry is None: - zoom = self._elem.webFrame().zoomFactor() - if not config.get('ui', 'zoom-text-only'): - rect.moveTo(rect.left() / zoom, rect.top() / zoom) - rect.setWidth(rect.width() / zoom) - rect.setHeight(rect.height() / zoom) return rect - def rect_on_view(self, *, elem_geometry=None, adjust_zoom=True, - no_js=False): + def rect_on_view(self, *, elem_geometry=None, no_js=False): """Get the geometry of the element relative to the webview. Uses the getClientRects() JavaScript method to obtain the collection of @@ -258,18 +216,14 @@ class WebKitElement(webelem.AbstractWebElement): elem_geometry: The geometry of the element, or None. Calling QWebElement::geometry is rather expensive so we want to avoid doing it twice. - adjust_zoom: Whether to adjust the element position based on the - current zoom level. no_js: Fall back to the Python implementation """ - # FIXME:qtwebengine can we get rid of this with - # find_all_elements(only_visible=True)? self._check_vanished() # First try getting the element rect via JS, as that's usually more # accurate if elem_geometry is None and not no_js: - rect = self._rect_on_view_js(adjust_zoom) + rect = self._rect_on_view_js() if rect is not None: return rect @@ -349,5 +303,6 @@ def focus_elem(frame): Args: frame: The QWebFrame to search in. """ + # FIXME:qtwebengine get rid of this elem = frame.findFirstElement('*:focus') - return WebKitElement(elem) + return WebKitElement(elem, tab=None) diff --git a/qutebrowser/browser/webkit/webkithistory.py b/qutebrowser/browser/webkit/webkithistory.py new file mode 100644 index 000000000..abd80eb39 --- /dev/null +++ b/qutebrowser/browser/webkit/webkithistory.py @@ -0,0 +1,61 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015-2016 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 . + +"""QtWebKit specific part of history.""" + + +from PyQt5.QtWebKit import QWebHistoryInterface + + +class WebHistoryInterface(QWebHistoryInterface): + + """Glue code between WebHistory and Qt's QWebHistoryInterface. + + Attributes: + _history: The WebHistory object. + """ + + def __init__(self, webhistory, parent=None): + super().__init__(parent) + self._history = webhistory + + def addHistoryEntry(self, url_string): + """Required for a QWebHistoryInterface impl, obsoleted by add_url.""" + pass + + def historyContains(self, url_string): + """Called by WebKit to determine if a URL is contained in the history. + + Args: + url_string: The URL (as string) to check for. + + Return: + True if the url is in the history, False otherwise. + """ + return url_string in self._history.history_dict + + +def init(history): + """Initialize the QWebHistoryInterface. + + Args: + history: The WebHistory object. + """ + interface = WebHistoryInterface(history, parent=history) + QWebHistoryInterface.setDefaultInterface(interface) diff --git a/qutebrowser/browser/webkit/webkitsettings.py b/qutebrowser/browser/webkit/webkitsettings.py index 0e8170231..d5af1dfd9 100644 --- a/qutebrowser/browser/webkit/webkitsettings.py +++ b/qutebrowser/browser/webkit/webkitsettings.py @@ -34,22 +34,30 @@ from qutebrowser.utils import standarddir, objreg class Attribute(websettings.Attribute): + """A setting set via QWebSettings::setAttribute.""" + GLOBAL_SETTINGS = QWebSettings.globalSettings ENUM_BASE = QWebSettings class Setter(websettings.Setter): + """A setting set via QWebSettings getter/setter methods.""" + GLOBAL_SETTINGS = QWebSettings.globalSettings class NullStringSetter(websettings.NullStringSetter): + """A setter for settings requiring a null QString as default.""" + GLOBAL_SETTINGS = QWebSettings.globalSettings class StaticSetter(websettings.StaticSetter): + """A setting set via static QWebSettings getter/setter methods.""" + GLOBAL_SETTINGS = QWebSettings.globalSettings diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 47f866559..a8895f6e5 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -32,7 +32,7 @@ from PyQt5.QtPrintSupport import QPrinter from qutebrowser.browser import browsertab from qutebrowser.browser.webkit import webview, tabhistory, webkitelem -from qutebrowser.utils import qtutils, objreg, usertypes, utils +from qutebrowser.utils import qtutils, objreg, usertypes, utils, log class WebKitPrinting(browsertab.AbstractPrinting): @@ -315,7 +315,7 @@ class WebKitCaret(browsertab.AbstractCaret): if QWebSettings.globalSettings().testAttribute( QWebSettings.JavascriptEnabled): if tab: - self._widget.page().open_target = usertypes.ClickTarget.tab + self._tab.data.open_target = usertypes.ClickTarget.tab self._tab.run_js_async( 'window.getSelection().anchorNode.parentNode.click()') else: @@ -491,6 +491,85 @@ class WebKitHistory(browsertab.AbstractHistory): self._tab.scroller.to_point, cur_data['scroll-pos'])) +class WebKitElements(browsertab.AbstractElements): + + """QtWebKit implemementations related to elements on the page.""" + + def find_css(self, selector, callback, *, only_visible=False): + mainframe = self._widget.page().mainFrame() + if mainframe is None: + raise browsertab.WebTabError("No frame focused!") + + elems = [] + frames = webkitelem.get_child_frames(mainframe) + for f in frames: + for elem in f.findAllElements(selector): + elems.append(webkitelem.WebKitElement(elem, tab=self._tab)) + + if only_visible: + elems = [e for e in elems if e.is_visible(mainframe)] + + callback(elems) + + def find_id(self, elem_id, callback): + def find_id_cb(elems): + if not elems: + callback(None) + else: + callback(elems[0]) + self.find_css('#' + elem_id, find_id_cb) + + def find_focused(self, callback): + frame = self._widget.page().currentFrame() + if frame is None: + callback(None) + return + + elem = frame.findFirstElement('*:focus') + if elem.isNull(): + callback(None) + else: + callback(webkitelem.WebKitElement(elem, tab=self._tab)) + + def find_at_pos(self, pos, callback): + assert pos.x() >= 0 + assert pos.y() >= 0 + frame = self._widget.page().frameAt(pos) + if frame is None: + # This happens when we click inside the webview, but not actually + # on the QWebPage - for example when clicking the scrollbar + # sometimes. + log.webview.debug("Hit test at {} but frame is None!".format(pos)) + callback(None) + return + + # You'd think we have to subtract frame.geometry().topLeft() from the + # position, but it seems QWebFrame::hitTestContent wants a position + # relative to the QWebView, not to the frame. This makes no sense to + # me, but it works this way. + hitresult = frame.hitTestContent(pos) + if hitresult.isNull(): + # For some reason, the whole hit result can be null sometimes (e.g. + # on doodle menu links). If this is the case, we schedule a check + # later (in mouseReleaseEvent) which uses webkitelem.focus_elem. + log.webview.debug("Hit test result is null!") + callback(None) + return + + try: + elem = webkitelem.WebKitElement(hitresult.element(), tab=self._tab) + except webkitelem.IsNullError: + # For some reason, the hit result element can be a null element + # sometimes (e.g. when clicking the timetable fields on + # http://www.sbb.ch/ ). If this is the case, we schedule a check + # later (in mouseReleaseEvent) which uses webelem.focus_elem. + log.webview.debug("Hit test result element is null!") + callback(None) + return + + callback(elem) + + class WebKitTab(browsertab.AbstractTab): """A QtWebKit tab in the browser.""" @@ -505,17 +584,25 @@ class WebKitTab(browsertab.AbstractTab): self.zoom = WebKitZoom(win_id=win_id, parent=self) self.search = WebKitSearch(parent=self) self.printing = WebKitPrinting() + self.elements = WebKitElements(self) self._set_widget(widget) self._connect_signals() self.zoom.set_default() self.backend = usertypes.Backend.QtWebKit + def _install_event_filter(self): + self._widget.installEventFilter(self._mouse_event_filter) + def openurl(self, url): self._openurl_prepare(url) self._widget.openurl(url) - def url(self): - return self._widget.url() + def url(self, requested=False): + frame = self._widget.page().mainFrame() + if requested: + return frame.requestedUrl() + else: + return frame.url() def dump_async(self, callback, *, plain=False): frame = self._widget.page().mainFrame() @@ -525,13 +612,10 @@ class WebKitTab(browsertab.AbstractTab): callback(frame.toHtml()) def run_js_async(self, code, callback=None): - result = self.run_js_blocking(code) + result = self._widget.page().mainFrame().evaluateJavaScript(code) if callback is not None: callback(result) - def run_js_blocking(self, code): - return self._widget.page().mainFrame().evaluateJavaScript(code) - def icon(self): return self._widget.icon() @@ -555,37 +639,15 @@ class WebKitTab(browsertab.AbstractTab): nam = self._widget.page().networkAccessManager() nam.clear_all_ssl_errors() + @pyqtSlot() + def _on_history_trigger(self): + url = self.url() + requested_url = self.url(requested=True) + self.add_history_item.emit(url, requested_url, self.title()) + def set_html(self, html, base_url): self._widget.setHtml(html, base_url) - def find_all_elements(self, selector, callback, *, only_visible=False): - mainframe = self._widget.page().mainFrame() - if mainframe is None: - raise browsertab.WebTabError("No frame focused!") - - elems = [] - frames = webkitelem.get_child_frames(mainframe) - for f in frames: - for elem in f.findAllElements(selector): - elems.append(webkitelem.WebKitElement(elem)) - - if only_visible: - elems = [e for e in elems if e.is_visible(mainframe)] - - callback(elems) - - def find_focus_element(self, callback): - frame = self._widget.page().currentFrame() - if frame is None: - callback(None) - return - - elem = frame.findFirstElement('*:focus') - if elem.isNull(): - callback(None) - else: - callback(webkitelem.WebKitElement(elem)) - @pyqtSlot() def _on_frame_load_finished(self): """Make sure we emit an appropriate status when loading finished. @@ -630,3 +692,8 @@ class WebKitTab(browsertab.AbstractTab): view.iconChanged.connect(self._on_webkit_icon_changed) page.frameCreated.connect(self._on_frame_created) frame.contentsSizeChanged.connect(self._on_contents_size_changed) + frame.initialLayoutCompleted.connect(self._on_history_trigger) + page.link_clicked.connect(self._on_link_clicked) + + def _event_target(self): + return self._widget diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index a3d2a018e..fc9ff1686 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -26,14 +26,14 @@ from PyQt5.QtGui import QDesktopServices from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from PyQt5.QtWidgets import QFileDialog from PyQt5.QtPrintSupport import QPrintDialog -from PyQt5.QtWebKitWidgets import QWebPage +from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from qutebrowser.config import config from qutebrowser.browser import pdfjs from qutebrowser.browser.webkit import http from qutebrowser.browser.webkit.network import networkmanager from qutebrowser.utils import (message, usertypes, log, jinja, qtutils, utils, - objreg, debug, urlutils) + objreg, debug) class BrowserPage(QWebPage): @@ -42,27 +42,28 @@ class BrowserPage(QWebPage): Attributes: error_occurred: Whether an error occurred while loading. - open_target: Where to open the next navigation request. - ("normal", "tab", "tab_bg") - _hint_target: Override for open_target while hinting, or None. _extension_handlers: Mapping of QWebPage extensions to their handlers. _networkmanager: The NetworkManager used. _win_id: The window ID this BrowserPage is associated with. _ignore_load_started: Whether to ignore the next loadStarted signal. _is_shutting_down: Whether the page is currently shutting down. + _tabdata: The TabData object of the tab this page is in. Signals: shutting_down: Emitted when the page is currently shutting down. reloading: Emitted before a web page reloads. arg: The URL which gets reloaded. + link_clicked: Emitted when a link was clicked on a page. """ shutting_down = pyqtSignal() reloading = pyqtSignal(QUrl) + link_clicked = pyqtSignal(QUrl) - def __init__(self, win_id, tab_id, parent=None): + def __init__(self, win_id, tab_id, tabdata, parent=None): super().__init__(parent) self._win_id = win_id + self._tabdata = tabdata self._is_shutting_down = False self._extension_handlers = { QWebPage.ErrorPageExtension: self._handle_errorpage, @@ -70,8 +71,6 @@ class BrowserPage(QWebPage): } self._ignore_load_started = False self.error_occurred = False - self.open_target = usertypes.ClickTarget.normal - self._hint_target = None self._networkmanager = networkmanager.NetworkManager( win_id, tab_id, self) self.setNetworkAccessManager(self._networkmanager) @@ -422,22 +421,6 @@ class BrowserPage(QWebPage): if 'scroll-pos' in data and frame.scrollPosition() == QPoint(0, 0): frame.setScrollPosition(data['scroll-pos']) - @pyqtSlot(usertypes.ClickTarget) - def on_start_hinting(self, hint_target): - """Emitted before a hinting-click takes place. - - Args: - hint_target: A ClickTarget member to set self._hint_target to. - """ - log.webview.debug("Setting force target to {}".format(hint_target)) - self._hint_target = hint_target - - @pyqtSlot() - def on_stop_hinting(self): - """Emitted when hinting is finished.""" - log.webview.debug("Finishing hinting.") - self._hint_target = None - def userAgentForUrl(self, url): """Override QWebPage::userAgentForUrl to customize the user agent.""" ua = config.get('network', 'user-agent') @@ -531,7 +514,10 @@ class BrowserPage(QWebPage): answer = True return answer - def acceptNavigationRequest(self, _frame, request, typ): + def acceptNavigationRequest(self, + _frame: QWebFrame, + request: QNetworkRequest, + typ: QWebPage.NavigationType): """Override acceptNavigationRequest to handle clicked links. Setting linkDelegationPolicy to DelegateAllLinks and using a slot bound @@ -539,48 +525,23 @@ class BrowserPage(QWebPage): have no idea in which frame the link should be opened. Checks if it should open it in a tab (middle-click or control) or not, - and then opens the URL. - - Args: - _frame: QWebFrame (target frame) - request: QNetworkRequest - typ: QWebPage::NavigationType + and then conditionally opens the URL. Opening it in a new tab/window + is handled in the slot connected to link_clicked. """ url = request.url() - urlstr = url.toDisplayString() + target = self._tabdata.combined_target() + log.webview.debug("navigation request: url {}, type {}, " + "target {}".format( + url.toDisplayString(), + debug.qenum_key(QWebPage, typ), + target)) + if typ == QWebPage.NavigationTypeReload: self.reloading.emit(url) - if typ != QWebPage.NavigationTypeLinkClicked: return True - if not url.isValid(): - msg = urlutils.get_errstring(url, "Invalid link clicked") - message.error(self._win_id, msg) - self.open_target = usertypes.ClickTarget.normal - return False - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=self._win_id) - log.webview.debug("acceptNavigationRequest, url {}, type {}, hint " - "target {}, open_target {}".format( - urlstr, debug.qenum_key(QWebPage, typ), - self._hint_target, self.open_target)) - if self._hint_target is not None: - target = self._hint_target - else: - target = self.open_target - self.open_target = usertypes.ClickTarget.normal - if target == usertypes.ClickTarget.tab: - tabbed_browser.tabopen(url, False) - return False - elif target == usertypes.ClickTarget.tab_bg: - tabbed_browser.tabopen(url, True) - return False - elif target == usertypes.ClickTarget.window: - from qutebrowser.mainwindow import mainwindow - window = mainwindow.MainWindow() - window.show() - tabbed_browser = objreg.get('tabbed-browser', scope='window', - window=window.win_id) - tabbed_browser.tabopen(url, False) - return False - else: + elif typ != QWebPage.NavigationTypeLinkClicked: return True + + self.link_clicked.emit(url) + + return url.isValid() and target == usertypes.ClickTarget.normal diff --git a/qutebrowser/browser/webkit/webview.py b/qutebrowser/browser/webkit/webview.py index adf2ea925..52e75ad14 100644 --- a/qutebrowser/browser/webkit/webview.py +++ b/qutebrowser/browser/webkit/webview.py @@ -21,16 +21,15 @@ import sys -from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QTimer, QUrl, QPoint +from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QUrl from PyQt5.QtGui import QPalette -from PyQt5.QtWidgets import QApplication, QStyleFactory +from PyQt5.QtWidgets import QStyleFactory from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWebKitWidgets import QWebView, QWebPage, QWebFrame from qutebrowser.config import config from qutebrowser.keyinput import modeman -from qutebrowser.utils import message, log, usertypes, utils, qtutils, objreg -from qutebrowser.browser import hints +from qutebrowser.utils import log, usertypes, utils, qtutils, objreg from qutebrowser.browser.webkit import webpage, webkitelem @@ -45,24 +44,16 @@ class WebView(QWebView): win_id: The window ID of the view. _tab_id: The tab ID of the view. _old_scroll_pos: The old scroll position. - _check_insertmode: If True, in mouseReleaseEvent we should check if we - need to enter/leave insert mode. - _ignore_wheel_event: Ignore the next wheel event. - See https://github.com/The-Compiler/qutebrowser/issues/395 Signals: scroll_pos_changed: Scroll percentage of current tab changed. arg 1: x-position in %. arg 2: y-position in %. - mouse_wheel_zoom: Emitted when the page should be zoomed because the - mousewheel was used with ctrl. - arg 1: The angle delta of the wheel event (QPoint) shutting_down: Emitted when the view is shutting down. """ scroll_pos_changed = pyqtSignal(int, int) shutting_down = pyqtSignal() - mouse_wheel_zoom = pyqtSignal(QPoint) def __init__(self, win_id, tab_id, tab, parent=None): super().__init__(parent) @@ -70,51 +61,32 @@ class WebView(QWebView): # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-42948 # See https://github.com/The-Compiler/qutebrowser/issues/462 self.setStyle(QStyleFactory.create('Fusion')) + # FIXME:qtwebengine this is only used to set the zoom factor from + # the QWebPage - we should get rid of it somehow (signals?) self.tab = tab self.win_id = win_id - self._check_insertmode = False self.scroll_pos = (-1, -1) self._old_scroll_pos = (-1, -1) - self._ignore_wheel_event = False self._set_bg_color() self._tab_id = tab_id - page = self._init_page() - hintmanager = hints.HintManager(win_id, self._tab_id, self) - hintmanager.mouse_event.connect(self.on_mouse_event) - hintmanager.start_hinting.connect(page.on_start_hinting) - hintmanager.stop_hinting.connect(page.on_stop_hinting) - objreg.register('hintmanager', hintmanager, scope='tab', window=win_id, - tab=tab_id) + self._init_page(tab.data) mode_manager = objreg.get('mode-manager', scope='window', window=win_id) mode_manager.entered.connect(self.on_mode_entered) mode_manager.left.connect(self.on_mode_left) - if config.get('input', 'rocker-gestures'): - self.setContextMenuPolicy(Qt.PreventContextMenu) - objreg.get('config').changed.connect(self.on_config_changed) + objreg.get('config').changed.connect(self._set_bg_color) - @pyqtSlot() - def on_initial_layout_completed(self): - """Add url to history now that we have displayed something.""" - history = objreg.get('web-history') - no_formatting = QUrl.UrlFormattingOption(0) - orig_url = self.page().mainFrame().requestedUrl() - if (orig_url.isValid() and - not orig_url.matches(self.url(), no_formatting)): - # If the url of the page is different than the url of the link - # originally clicked, save them both. - history.add_url(orig_url, self.title(), redirect=True) - history.add_url(self.url(), self.title()) + def _init_page(self, tabdata): + """Initialize the QWebPage used by this view. - def _init_page(self): - """Initialize the QWebPage used by this view.""" - page = webpage.BrowserPage(self.win_id, self._tab_id, self) + Args: + tabdata: The TabData object for this tab. + """ + page = webpage.BrowserPage(self.win_id, self._tab_id, tabdata, + parent=self) self.setPage(page) page.mainFrame().loadFinished.connect(self.on_load_finished) - page.mainFrame().initialLayoutCompleted.connect( - self.on_initial_layout_completed) - return page def __repr__(self): url = utils.elide(self.url().toDisplayString(QUrl.EncodeUnicode), 100) @@ -133,6 +105,7 @@ class WebView(QWebView): # deleted pass + @config.change_filter('colors', 'webpage.bg') def _set_bg_color(self): """Set the webpage background color as configured.""" col = config.get('colors', 'webpage.bg') @@ -142,126 +115,6 @@ class WebView(QWebView): palette.setColor(QPalette.Base, col) self.setPalette(palette) - @pyqtSlot(str, str) - def on_config_changed(self, section, option): - """Update rocker gestures/background color.""" - if section == 'input' and option == 'rocker-gestures': - if config.get('input', 'rocker-gestures'): - self.setContextMenuPolicy(Qt.PreventContextMenu) - else: - self.setContextMenuPolicy(Qt.DefaultContextMenu) - elif section == 'colors' and option == 'webpage.bg': - self._set_bg_color() - - def _mousepress_backforward(self, e): - """Handle back/forward mouse button presses. - - Args: - e: The QMouseEvent. - """ - if e.button() in [Qt.XButton1, Qt.LeftButton]: - # Back button on mice which have it, or rocker gesture - if self.page().history().canGoBack(): - self.back() - else: - message.error(self.win_id, "At beginning of history.", - immediately=True) - elif e.button() in [Qt.XButton2, Qt.RightButton]: - # Forward button on mice which have it, or rocker gesture - if self.page().history().canGoForward(): - self.forward() - else: - message.error(self.win_id, "At end of history.", - immediately=True) - - def _mousepress_insertmode(self, e): - """Switch to insert mode when an editable element was clicked. - - Args: - e: The QMouseEvent. - """ - pos = e.pos() - frame = self.page().frameAt(pos) - if frame is None: - # This happens when we click inside the webview, but not actually - # on the QWebPage - for example when clicking the scrollbar - # sometimes. - log.mouse.debug("Clicked at {} but frame is None!".format(pos)) - return - # You'd think we have to subtract frame.geometry().topLeft() from the - # position, but it seems QWebFrame::hitTestContent wants a position - # relative to the QWebView, not to the frame. This makes no sense to - # me, but it works this way. - hitresult = frame.hitTestContent(pos) - if hitresult.isNull(): - # For some reason, the whole hit result can be null sometimes (e.g. - # on doodle menu links). If this is the case, we schedule a check - # later (in mouseReleaseEvent) which uses webkitelem.focus_elem. - log.mouse.debug("Hitresult is null!") - self._check_insertmode = True - return - try: - elem = webkitelem.WebKitElement(hitresult.element()) - except webkitelem.IsNullError: - # For some reason, the hit result element can be a null element - # sometimes (e.g. when clicking the timetable fields on - # http://www.sbb.ch/ ). If this is the case, we schedule a check - # later (in mouseReleaseEvent) which uses webelem.focus_elem. - log.mouse.debug("Hitresult element is null!") - self._check_insertmode = True - return - if ((hitresult.isContentEditable() and elem.is_writable()) or - elem.is_editable()): - log.mouse.debug("Clicked editable element!") - modeman.enter(self.win_id, usertypes.KeyMode.insert, 'click', - only_if_normal=True) - else: - log.mouse.debug("Clicked non-editable element!") - if config.get('input', 'auto-leave-insert-mode'): - modeman.maybe_leave(self.win_id, usertypes.KeyMode.insert, - 'click') - - def mouserelease_insertmode(self): - """If we have an insertmode check scheduled, handle it.""" - # FIXME:qtwebengine Use tab.find_focus_element here - if not self._check_insertmode: - return - self._check_insertmode = False - try: - elem = webkitelem.focus_elem(self.page().currentFrame()) - except (webkitelem.IsNullError, RuntimeError): - log.mouse.debug("Element/page vanished!") - return - if elem.is_editable(): - log.mouse.debug("Clicked editable element (delayed)!") - modeman.enter(self.win_id, usertypes.KeyMode.insert, - 'click-delayed', only_if_normal=True) - else: - log.mouse.debug("Clicked non-editable element (delayed)!") - if config.get('input', 'auto-leave-insert-mode'): - modeman.maybe_leave(self.win_id, usertypes.KeyMode.insert, - 'click-delayed') - - def _mousepress_opentarget(self, e): - """Set the open target when something was clicked. - - Args: - e: The QMouseEvent. - """ - if e.button() == Qt.MidButton or e.modifiers() & Qt.ControlModifier: - background_tabs = config.get('tabs', 'background-tabs') - if e.modifiers() & Qt.ShiftModifier: - background_tabs = not background_tabs - if background_tabs: - target = usertypes.ClickTarget.tab_bg - else: - target = usertypes.ClickTarget.tab - self.page().open_target = target - log.mouse.debug("Middle click, setting target: {}".format(target)) - else: - self.page().open_target = usertypes.ClickTarget.normal - log.mouse.debug("Normal click, setting normal target") - def shutdown(self): """Shut down the webview.""" self.shutting_down.emit() @@ -297,13 +150,6 @@ class WebView(QWebView): bridge = objreg.get('js-bridge') frame.addToJavaScriptWindowObject('qute', bridge) - @pyqtSlot('QMouseEvent') - def on_mouse_event(self, evt): - """Post a new mouse event from a hintmanager.""" - log.modes.debug("Hint triggered, focusing {!r}".format(self)) - self.setFocus() - QApplication.postEvent(self, evt) - @pyqtSlot() def on_load_finished(self): """Handle a finished page load. @@ -407,59 +253,9 @@ class WebView(QWebView): # Let superclass handle the event super().paintEvent(e) - def mousePressEvent(self, e): - """Extend QWidget::mousePressEvent(). - - This does the following things: - - Check if a link was clicked with the middle button or Ctrl and - set the page's open_target attribute accordingly. - - Emit the editable_elem_selected signal if an editable element was - clicked. - - Args: - e: The arrived event. - - Return: - The superclass return value. - """ - is_rocker_gesture = (config.get('input', 'rocker-gestures') and - e.buttons() == Qt.LeftButton | Qt.RightButton) - - if e.button() in [Qt.XButton1, Qt.XButton2] or is_rocker_gesture: - self._mousepress_backforward(e) - super().mousePressEvent(e) - return - self._mousepress_insertmode(e) - self._mousepress_opentarget(e) - self._ignore_wheel_event = True - super().mousePressEvent(e) - - def mouseReleaseEvent(self, e): - """Extend mouseReleaseEvent to enter insert mode if needed.""" - super().mouseReleaseEvent(e) - # We want to make sure we check the focus element after the WebView is - # updated completely. - QTimer.singleShot(0, self.mouserelease_insertmode) - def contextMenuEvent(self, e): """Save a reference to the context menu so we can close it.""" menu = self.page().createStandardContextMenu() self.shutting_down.connect(menu.close) modeman.instance(self.win_id).entered.connect(menu.close) menu.exec_(e.globalPos()) - - def wheelEvent(self, e): - """Zoom on Ctrl-Mousewheel. - - Args: - e: The QWheelEvent. - """ - if self._ignore_wheel_event: - self._ignore_wheel_event = False - # See https://github.com/The-Compiler/qutebrowser/issues/395 - return - if e.modifiers() & Qt.ControlModifier: - e.accept() - self.mouse_wheel_zoom.emit(e.angleDelta()) - else: - super().wheelEvent(e) diff --git a/qutebrowser/commands/__init__.py b/qutebrowser/commands/__init__.py index 07a5cba0a..bf44e449a 100644 --- a/qutebrowser/commands/__init__.py +++ b/qutebrowser/commands/__init__.py @@ -17,4 +17,15 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . -"""Utilities and classes regarding to commands.""" +"""In qutebrowser, all keybindings are mapped to commands. + +Some commands are hidden, which means they don't show up in the command +completion when pressing `:`, as they're typically not useful to run by hand. + +In the commandline, there are also some variables you can use: + +- `{url}` expands to the URL of the current page +- `{url:pretty}` expands to the URL in decoded format +- `{clipboard}` expands to the clipboard contents +- `{primary}` expands to the primary selection contents +""" diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 323513604..970fbadbe 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -21,12 +21,13 @@ import collections import traceback +import re from PyQt5.QtCore import pyqtSlot, QUrl, QObject from qutebrowser.config import config, configexc from qutebrowser.commands import cmdexc, cmdutils -from qutebrowser.utils import message, objreg, qtutils +from qutebrowser.utils import message, objreg, qtutils, utils from qutebrowser.misc import split @@ -49,21 +50,35 @@ def _current_url(tabbed_browser): def replace_variables(win_id, arglist): """Utility function to replace variables like {url} in a list of args.""" + variables = { + 'url': lambda: _current_url(tabbed_browser).toString( + QUrl.FullyEncoded | QUrl.RemovePassword), + 'url:pretty': lambda: _current_url(tabbed_browser).toString( + QUrl.RemovePassword), + 'clipboard': utils.get_clipboard, + 'primary': lambda: utils.get_clipboard(selection=True), + } + values = {} args = [] tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) - if any('{url}' in arg for arg in arglist): - url = _current_url(tabbed_browser).toString(QUrl.FullyEncoded | - QUrl.RemovePassword) - if any('{url:pretty}' in arg for arg in arglist): - pretty_url = _current_url(tabbed_browser).toString(QUrl.RemovePassword) - for arg in arglist: - if '{url}' in arg: - args.append(arg.replace('{url}', url)) - elif '{url:pretty}' in arg: - args.append(arg.replace('{url:pretty}', pretty_url)) - else: - args.append(arg) + + def repl_cb(matchobj): + """Return replacement for given match.""" + var = matchobj.group("var") + if var not in values: + values[var] = variables[var]() + return values[var] + repl_pattern = re.compile("{(?P" + "|".join(variables.keys()) + ")}") + + try: + for arg in arglist: + # using re.sub with callback function replaces all variables in a + # single pass and avoids expansion of nested variables (e.g. + # "{url}" from clipboard is not expanded) + args.append(repl_pattern.sub(repl_cb, arg)) + except utils.ClipboardError as e: + raise cmdexc.CommandError(e) return args diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 865a892eb..65ba20a32 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -180,20 +180,58 @@ class CompletionView(QTreeView): # Item is a real item, not a category header -> success return idx + def _next_category_idx(self, upwards): + """Get the index of the previous/next category. + + Args: + upwards: Get previous item, not next. + + Return: + A QModelIndex. + """ + idx = self.selectionModel().currentIndex() + if not idx.isValid(): + return self._next_idx(upwards).sibling(0, 0) + idx = idx.parent() + direction = -1 if upwards else 1 + while True: + idx = idx.sibling(idx.row() + direction, 0) + if not idx.isValid() and upwards: + # wrap around to the first item of the last category + return self.model().last_item().sibling(0, 0) + elif not idx.isValid() and not upwards: + # wrap around to the first item of the first category + idx = self.model().first_item() + self.scrollTo(idx.parent()) + return idx + elif idx.isValid() and idx.child(0, 0).isValid(): + # scroll to ensure the category is visible + self.scrollTo(idx) + return idx.child(0, 0) + @cmdutils.register(instance='completion', hide=True, modes=[usertypes.KeyMode.command], scope='window') - @cmdutils.argument('which', choices=['next', 'prev']) + @cmdutils.argument('which', choices=['next', 'prev', 'next-category', + 'prev-category']) def completion_item_focus(self, which): """Shift the focus of the completion menu to another item. Args: - which: 'next' or 'prev' + which: 'next', 'prev', 'next-category', or 'prev-category'. """ if not self._active: return selmodel = self.selectionModel() - idx = self._next_idx(which == 'prev') + if which == 'next': + idx = self._next_idx(upwards=False) + elif which == 'prev': + idx = self._next_idx(upwards=True) + elif which == 'next-category': + idx = self._next_category_idx(upwards=False) + elif which == 'prev-category': + idx = self._next_category_idx(upwards=True) + if not idx.isValid(): return diff --git a/qutebrowser/completion/models/instances.py b/qutebrowser/completion/models/instances.py index 568607b79..6359d3771 100644 --- a/qutebrowser/completion/models/instances.py +++ b/qutebrowser/completion/models/instances.py @@ -29,7 +29,7 @@ import functools from qutebrowser.completion.models import miscmodels, urlmodel, configmodel from qutebrowser.utils import objreg, usertypes, log, debug -from qutebrowser.config import configdata +from qutebrowser.config import configdata, config _instances = {} @@ -114,6 +114,13 @@ def init_session_completion(): _instances[usertypes.Completion.sessions] = model +def _init_bind_completion(): + """Initialize the command completion model.""" + log.completion.debug("Initializing bind completion.") + model = miscmodels.BindCompletionModel() + _instances[usertypes.Completion.bind] = model + + INITIALIZERS = { usertypes.Completion.command: _init_command_completion, usertypes.Completion.helptopic: _init_helptopic_completion, @@ -125,6 +132,7 @@ INITIALIZERS = { usertypes.Completion.quickmark_by_name: init_quickmark_completions, usertypes.Completion.bookmark_by_url: init_bookmark_completions, usertypes.Completion.sessions: init_session_completion, + usertypes.Completion.bind: _init_bind_completion, } @@ -155,6 +163,12 @@ def update(completions): did_run.append(func) +@config.change_filter('aliases', function=True) +def _update_aliases(): + """Update completions that include command aliases.""" + update([usertypes.Completion.command]) + + def init(): """Initialize completions. Note this only connects signals.""" quickmark_manager = objreg.get('quickmark-manager') @@ -176,3 +190,7 @@ def init(): keyconf = objreg.get('key-config') keyconf.changed.connect( functools.partial(update, [usertypes.Completion.command])) + keyconf.changed.connect( + functools.partial(update, [usertypes.Completion.bind])) + + objreg.get('config').changed.connect(_update_aliases) diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index bbbaa8a9d..14a48c399 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -30,7 +30,7 @@ from qutebrowser.completion.models import base class CommandCompletionModel(base.BaseCompletionModel): - """A CompletionModel filled with all commands and descriptions.""" + """A CompletionModel filled with non-hidden commands and descriptions.""" # https://github.com/The-Compiler/qutebrowser/issues/545 # pylint: disable=abstract-method @@ -39,23 +39,11 @@ class CommandCompletionModel(base.BaseCompletionModel): def __init__(self, parent=None): super().__init__(parent) - assert cmdutils.cmd_dict - cmdlist = [] - for obj in set(cmdutils.cmd_dict.values()): - if (obj.hide or (obj.debug and not objreg.get('args').debug) or - obj.deprecated): - pass - else: - cmdlist.append((obj.name, obj.desc)) - for name, cmd in config.section('aliases').items(): - cmdlist.append((name, "Alias for '{}'".format(cmd))) + cmdlist = _get_cmd_completions(include_aliases=True, + include_hidden=False) cat = self.new_category("Commands") - - # map each command to its bound keys and show these in the misc column - key_config = objreg.get('key-config') - cmd_to_keys = key_config.get_reverse_bindings_for('normal') - for (name, desc) in sorted(cmdlist): - self.new_item(cat, name, desc, ', '.join(cmd_to_keys[name])) + for (name, desc, misc) in cmdlist: + self.new_item(cat, name, desc, misc) class HelpCompletionModel(base.BaseCompletionModel): @@ -72,17 +60,11 @@ class HelpCompletionModel(base.BaseCompletionModel): def _init_commands(self): """Fill completion with :command entries.""" - assert cmdutils.cmd_dict - cmdlist = [] - for obj in set(cmdutils.cmd_dict.values()): - if (obj.hide or (obj.debug and not objreg.get('args').debug) or - obj.deprecated): - pass - else: - cmdlist.append((':' + obj.name, obj.desc)) + cmdlist = _get_cmd_completions(include_aliases=False, + include_hidden=True, prefix=':') cat = self.new_category("Commands") - for (name, desc) in sorted(cmdlist): - self.new_item(cat, name, desc) + for (name, desc, misc) in cmdlist: + self.new_item(cat, name, desc, misc) def _init_settings(self): """Fill completion with section->option entries.""" @@ -166,7 +148,8 @@ class TabCompletionModel(base.BaseCompletionModel): def __init__(self, parent=None): super().__init__(parent) - self.columns_to_filter = [self.URL_COLUMN, self.TEXT_COLUMN] + self.columns_to_filter = [self.IDX_COLUMN, self.URL_COLUMN, + self.TEXT_COLUMN] for win_id in objreg.window_registry: tabbed_browser = objreg.get('tabbed-browser', scope='window', @@ -259,3 +242,49 @@ class TabCompletionModel(base.BaseCompletionModel): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=int(win_id)) tabbed_browser.on_tab_close_requested(int(tab_index) - 1) + + +class BindCompletionModel(base.BaseCompletionModel): + + """A CompletionModel filled with all bindable commands and descriptions.""" + + # https://github.com/The-Compiler/qutebrowser/issues/545 + # pylint: disable=abstract-method + + COLUMN_WIDTHS = (20, 60, 20) + + def __init__(self, parent=None): + super().__init__(parent) + cmdlist = _get_cmd_completions(include_hidden=True, + include_aliases=True) + cat = self.new_category("Commands") + for (name, desc, misc) in cmdlist: + self.new_item(cat, name, desc, misc) + + +def _get_cmd_completions(include_hidden, include_aliases, prefix=''): + """Get a list of completions info for commands, sorted by name. + + Args: + include_hidden: True to include commands annotated with hide=True. + include_aliases: True to include command aliases. + prefix: String to append to the command name. + + Return: A list of tuples of form (name, description, bindings). + """ + assert cmdutils.cmd_dict + cmdlist = [] + cmd_to_keys = objreg.get('key-config').get_reverse_bindings_for('normal') + for obj in set(cmdutils.cmd_dict.values()): + hide_debug = obj.debug and not objreg.get('args').debug + hide_hidden = obj.hide and not include_hidden + if not (hide_debug or hide_hidden or obj.deprecated): + bindings = ', '.join(cmd_to_keys.get(obj.name, [])) + cmdlist.append((prefix + obj.name, obj.desc, bindings)) + + if include_aliases: + for name, cmd in config.section('aliases').items(): + bindings = ', '.join(cmd_to_keys.get(name, [])) + cmdlist.append((name, "Alias for '{}'".format(cmd), bindings)) + + return cmdlist diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 5de647fb7..636e3a4d0 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -24,6 +24,7 @@ are fundamentally different. This is why nothing inherits from configparser, but we borrow some methods and classes from there where it makes sense. """ +import re import os import sys import os.path @@ -34,9 +35,11 @@ import collections import collections.abc from PyQt5.QtCore import pyqtSignal, QObject, QUrl, QSettings +from PyQt5.QtGui import QColor from qutebrowser.config import configdata, configexc, textwrapper -from qutebrowser.config.parsers import ini, keyconf +from qutebrowser.config.parsers import keyconf +from qutebrowser.config.parsers import ini from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.utils import (message, objreg, utils, standarddir, log, qtutils, error, usertypes) @@ -285,6 +288,50 @@ def _transform_position(val): return val +def _transform_hint_color(val): + """Transformer for hint colors.""" + log.config.debug("Transforming hint value {}".format(val)) + + def to_rgba(qcolor): + """Convert a QColor to a rgba() value.""" + return 'rgba({}, {}, {}, 0.8)'.format(qcolor.red(), qcolor.green(), + qcolor.blue()) + + if val.startswith('-webkit-gradient'): + pattern = re.compile(r'-webkit-gradient\(linear, left top, ' + r'left bottom, ' + r'color-stop\(0%, *([^)]*)\), ' + r'color-stop\(100%, *([^)]*)\)\)') + + match = pattern.fullmatch(val) + if match: + log.config.debug('Color groups: {}'.format(match.groups())) + start_color = QColor(match.group(1)) + stop_color = QColor(match.group(2)) + if not start_color.isValid() or not stop_color.isValid(): + return None + + return ('qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {}, ' + 'stop:1 {})'.format(to_rgba(start_color), + to_rgba(stop_color))) + else: + return None + elif val.startswith('-'): # Custom CSS stuff? + return None + else: # Already transformed or a named color. + return val + + +def _transform_hint_font(val): + """Transformer for fonts -> hints.""" + match = re.fullmatch(r'(.*\d+p[xt]) Monospace', val) + if match: + # Close enough to the old default: + return match.group(1) + ' ${_monospace}' + else: + return val + + class ConfigManager(QObject): """Configuration manager for qutebrowser. @@ -350,7 +397,9 @@ class ConfigManager(QObject): ('tabs', 'auto-hide'), ('tabs', 'hide-always'), ('ui', 'display-statusbar-messages'), + ('ui', 'hide-mouse-cursor'), ('general', 'wrap-search'), + ('hints', 'opacity'), ('completion', 'auto-open'), ] CHANGED_OPTIONS = { @@ -364,6 +413,12 @@ class ConfigManager(QObject): _get_value_transformer({'false': 'none', 'true': 'debug'}), ('ui', 'keyhint-blacklist'): _get_value_transformer({'false': '*', 'true': ''}), + ('hints', 'auto-follow'): + _get_value_transformer({'false': 'never', 'true': 'unique-match'}), + ('colors', 'hints.bg'): _transform_hint_color, + ('colors', 'hints.fg'): _transform_hint_color, + ('colors', 'hints.fg.match'): _transform_hint_color, + ('fonts', 'hints'): _transform_hint_font, ('completion', 'show'): _get_value_transformer({'false': 'never', 'true': 'always'}), } @@ -524,7 +579,15 @@ class ConfigManager(QObject): k = self.RENAMED_OPTIONS[sectname, k] if (sectname, k) in self.CHANGED_OPTIONS: func = self.CHANGED_OPTIONS[(sectname, k)] - v = func(v) + new_v = func(v) + if new_v is None: + exc = configexc.ValidationError( + v, "Could not automatically migrate the given value") + exc.section = sectname + exc.option = k + raise exc + + v = new_v try: self.set('conf', sectname, k, v, validate=False) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index f3d6bc6e2..cf1a391c6 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -227,13 +227,27 @@ def data(readonly=False): "How to open links in an existing instance if a new one is " "launched."), + ('new-instance-open-target.window', + SettingValue(typ.String( + valid_values=typ.ValidValues( + ('first-opened', "Open new tabs in the first (oldest) " + "opened window."), + ('last-opened', "Open new tabs in the last (newest) " + "opened window."), + ('last-focused', "Open new tabs in the most recently " + "focused window."), + ('last-visible', "Open new tabs in the most recently " + "visible window.") + )), 'last-focused'), + "Which window to choose when opening links as new tabs."), + ('log-javascript-console', SettingValue(typ.String( valid_values=typ.ValidValues( ('none', "Don't log messages."), ('debug', "Log messages with debug level."), ('info', "Log messages with info level.") - )), 'debug', backends=[usertypes.Backend.QtWebKit]), + )), 'debug'), "How to log javascript console messages."), ('save-session', @@ -302,7 +316,8 @@ def data(readonly=False): ('user-stylesheet', SettingValue(typ.UserStyleSheet(none_ok=True), - '::-webkit-scrollbar { width: 0px; height: 0px; }', + 'html > ::-webkit-scrollbar { width: 0px; ' + 'height: 0px; }', backends=[usertypes.Backend.QtWebKit]), "User stylesheet to use (absolute filename, filename relative to " "the config directory or CSS string). Will expand environment " @@ -346,10 +361,6 @@ def data(readonly=False): "* `{scroll_pos}`: The page scroll position.\n" "* `{host}`: The host of the current web page."), - ('hide-mouse-cursor', - SettingValue(typ.Bool(), 'false'), - "Whether to hide the mouse cursor."), - ('modal-js-dialog', SettingValue(typ.Bool(), 'false'), "Use standard JavaScript modal dialog for alert() and confirm()"), @@ -640,7 +651,8 @@ def data(readonly=False): ('title-format', SettingValue(typ.FormatString( fields=['perc', 'perc_raw', 'title', 'title_sep', 'index', - 'id', 'scroll_pos', 'host']), '{index}: {title}'), + 'id', 'scroll_pos', 'host'], none_ok=True), + '{index}: {title}'), "The format to use for the tab title. The following placeholders " "are defined:\n\n" "* `{perc}`: The percentage as a string like `[10%]`.\n" @@ -887,10 +899,6 @@ def data(readonly=False): SettingValue(typ.String(), '1px solid #E3BE23'), "CSS border value for hints."), - ('opacity', - SettingValue(typ.Float(minval=0.0, maxval=1.0), '0.7'), - "Opacity for hints."), - ('mode', SettingValue(typ.String( valid_values=typ.ValidValues( @@ -928,9 +936,21 @@ def data(readonly=False): "The dictionary file to be used by the word hints."), ('auto-follow', - SettingValue(typ.Bool(), 'true'), - "Follow a hint immediately when the hint text is completely " - "matched."), + SettingValue(typ.String( + valid_values=typ.ValidValues( + ('always', "Auto-follow whenever there is only a single " + "hint on a page."), + ('unique-match', "Auto-follow whenever there is a unique " + "non-empty match in either the hint string (word mode) " + "or filter (number mode)."), + ('full-match', "Follow the hint when the user typed the " + "whole hint (letter, word or number mode) or the " + "element's text (only in number mode)."), + ('never', "The user will always need to press Enter to " + "follow a hint."), + )), 'unique-match'), + "Controls when a hint can be automatically followed without the " + "user pressing Enter."), ('auto-follow-timeout', SettingValue(typ.Int(), '0'), @@ -957,6 +977,10 @@ def data(readonly=False): )), 'python'), "Which implementation to use to find elements to hint."), + ('hide-unmatched-rapid-hints', + SettingValue(typ.Bool(), 'true'), + "Controls hiding unmatched hints in rapid mode."), + readonly=readonly )), @@ -1183,18 +1207,18 @@ def data(readonly=False): "Color gradient interpolation system for the tab indicator."), ('hints.fg', - SettingValue(typ.CssColor(), 'black'), + SettingValue(typ.QssColor(), 'black'), "Font color for hints."), ('hints.bg', - SettingValue( - typ.CssColor(), '-webkit-gradient(linear, left top, ' - 'left bottom, color-stop(0%,#FFF785), ' - 'color-stop(100%,#FFC542))'), - "Background color for hints."), + SettingValue(typ.QssColor(), 'qlineargradient(x1:0, y1:0, x2:0, ' + 'y2:1, stop:0 rgba(255, 247, 133, 0.8), ' + 'stop:1 rgba(255, 197, 66, 0.8))'), + "Background color for hints. Note that you can use a `rgba(...)` " + "value for transparency."), ('hints.fg.match', - SettingValue(typ.CssColor(), 'green'), + SettingValue(typ.QssColor(), 'green'), "Font color for the matched part of hints."), ('downloads.bg.bar', @@ -1267,7 +1291,7 @@ def data(readonly=False): "Font used in the completion widget."), ('completion.category', - SettingValue(typ.Font(), 'bold ${completion}'), + SettingValue(typ.Font(), 'bold ${completion}'), "Font used in the completion categories."), ('tabbar', @@ -1283,7 +1307,7 @@ def data(readonly=False): "Font used for the downloadbar."), ('hints', - SettingValue(typ.Font(), 'bold 13px Monospace'), + SettingValue(typ.Font(), 'bold 13px ${_monospace}'), "Font used for the hints."), ('debug-console', @@ -1441,7 +1465,7 @@ RETURN_KEYS = ['', '', '', '', '', KEY_DATA = collections.OrderedDict([ ('!normal', collections.OrderedDict([ - ('clear-keychain ;; leave-mode', ['', '']), + ('leave-mode', ['', '']), ])), ('normal', collections.OrderedDict([ @@ -1454,6 +1478,9 @@ KEY_DATA = collections.OrderedDict([ ('set-cmd-text :open -b -i {url:pretty}', ['xO']), ('set-cmd-text -s :open -w', ['wo']), ('set-cmd-text :open -w {url:pretty}', ['wO']), + ('set-cmd-text /', ['/']), + ('set-cmd-text ?', ['?']), + ('set-cmd-text :', [':']), ('open -t', ['ga', '']), ('open -w', ['']), ('tab-close', ['d', '']), @@ -1512,12 +1539,12 @@ KEY_DATA = collections.OrderedDict([ ('yank domain -s', ['yD']), ('yank pretty-url', ['yp']), ('yank pretty-url -s', ['yP']), - ('paste', ['pp']), - ('paste -s', ['pP']), - ('paste -t', ['Pp']), - ('paste -ts', ['PP']), - ('paste -w', ['wp']), - ('paste -ws', ['wP']), + ('open -- {clipboard}', ['pp']), + ('open -- {primary}', ['pP']), + ('open -t -- {clipboard}', ['Pp']), + ('open -t -- {primary}', ['PP']), + ('open -w -- {clipboard}', ['wp']), + ('open -w -- {primary}', ['wP']), ('quickmark-save', ['m']), ('set-cmd-text -s :quickmark-load', ['b']), ('set-cmd-text -s :quickmark-load -t', ['B']), @@ -1574,7 +1601,7 @@ KEY_DATA = collections.OrderedDict([ ('insert', collections.OrderedDict([ ('open-editor', ['']), - ('paste-primary', ['']), + ('insert-text {primary}', ['']), ])), ('hint', collections.OrderedDict([ @@ -1591,6 +1618,8 @@ KEY_DATA = collections.OrderedDict([ ('command-history-next', ['']), ('completion-item-focus prev', ['', '']), ('completion-item-focus next', ['', '']), + ('completion-item-focus next-category', ['']), + ('completion-item-focus prev-category', ['']), ('completion-item-del', ['']), ('command-accept', RETURN_KEYS), ])), @@ -1672,7 +1701,7 @@ CHANGED_KEY_COMMANDS = [ (re.compile(r'^scroll ([-\d]+ [-\d]+)$'), r'scroll-px \1'), (re.compile(r'^search *;; *clear-keychain$'), r'clear-keychain ;; search'), - (re.compile(r'^leave-mode$'), r'clear-keychain ;; leave-mode'), + (re.compile(r'^clear-keychain *;; *leave-mode$'), r'leave-mode'), (re.compile(r'^download-remove --all$'), r'download-clear'), @@ -1687,6 +1716,23 @@ CHANGED_KEY_COMMANDS = [ (re.compile(r'^yank-selected -p'), r'yank selection -s'), (re.compile(r'^yank-selected'), r'yank selection'), + (re.compile(r'^paste$'), r'open -- {clipboard}'), + (re.compile(r'^paste -s$'), r'open -- {primary}'), + (re.compile(r'^paste -([twb])$'), r'open -\1 -- {clipboard}'), + (re.compile(r'^paste -([twb])s$'), r'open -\1 -- {primary}'), + (re.compile(r'^paste -s([twb])$'), r'open -\1 -- {primary}'), + (re.compile(r'^completion-item-next'), r'completion-item-focus next'), (re.compile(r'^completion-item-prev'), r'completion-item-focus prev'), + + (re.compile(r'^open {clipboard}$'), r'open -- {clipboard}'), + (re.compile(r'^open -([twb]) {clipboard}$'), r'open -\1 -- {clipboard}'), + (re.compile(r'^open {primary}$'), r'open -- {primary}'), + (re.compile(r'^open -([twb]) {primary}$'), r'open -\1 -- {primary}'), + + (re.compile(r'^paste-primary$'), r'insert-text {primary}'), + + (re.compile(r'^set-cmd-text -s :search$'), r'set-cmd-text /'), + (re.compile(r'^set-cmd-text -s :search -r$'), r'set-cmd-text ?'), + (re.compile(r'^set-cmd-text -s :$'), r'set-cmd-text :'), ] diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 398a5c9bc..aaa158a02 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -125,11 +125,11 @@ class BaseType: self.valid_values = None def get_name(self): - """Get a name for the type for documentation""" + """Get a name for the type for documentation.""" return self.__class__.__name__ def get_valid_values(self): - """Get the type's valid values for documentation""" + """Get the type's valid values for documentation.""" return self.valid_values def _basic_validation(self, value): @@ -1477,35 +1477,35 @@ class UserAgent(BaseType): def complete(self): """Complete a list of common user agents.""" out = [ - ('Mozilla/5.0 (Windows NT 6.1; WOW64; rv:41.0) Gecko/20100101 ' - 'Firefox/41.0', - "Firefox 41.0 Win7 64-bit"), - ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:41.0) ' - 'Gecko/20100101 Firefox/41.0', - "Firefox 41.0 MacOSX"), - ('Mozilla/5.0 (X11; Linux x86_64; rv:41.0) Gecko/20100101 ' - 'Firefox/41.0', - "Firefox 41.0 Linux"), + ('Mozilla/5.0 (Windows NT 6.1; WOW64; rv:47.0) Gecko/20100101 ' + 'Firefox/47.0', + "Firefox Generic Win7"), + ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:47.0) ' + 'Gecko/20100101 Firefox/47.0', + "Firefox Generic MacOSX"), + ('Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:47.0) Gecko/20100101 ' + 'Firefox/47.0', + "Firefox Generic Linux"), - ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) ' - 'AppleWebKit/601.2.7 (KHTML, like Gecko) Version/9.0.1 ' - 'Safari/601.2.7', - "Safari Generic MacOSX"), - ('Mozilla/5.0 (iPad; CPU OS 9_1 like Mac OS X) ' + ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) ' + 'AppleWebKit/601.7.7 (KHTML, like Gecko) Version/9.1.2 ' + 'Safari/601.7.7', + "Safari Generic MacOSX"), + ('Mozilla/5.0 (iPad; CPU OS 9_3_2 like Mac OS X) ' 'AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 ' - 'Mobile/13B143 Safari/601.1', - "Mobile Safari Generic iOS"), + 'Mobile/13F69 Safari/601.1', + "Mobile Safari 9.0 iOS"), - ('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, ' - 'like Gecko) Chrome/46.0.2490.80 Safari/537.36', - "Chrome 46.0 Win7 64-bit"), - ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) ' - 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.80 ' + ('Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', + "Chrome Generic Win10"), + ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 ' 'Safari/537.36', - "Chrome 46.0 MacOSX"), - ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, ' - 'like Gecko) Chrome/46.0.2490.80 Safari/537.36', - "Chrome 46.0 Linux"), + "Chrome Generic MacOSX"), + ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36', + "Chrome Generic Linux"), ('Mozilla/5.0 (compatible; Googlebot/2.1; ' '+http://www.google.com/bot.html', @@ -1517,7 +1517,7 @@ class UserAgent(BaseType): ('Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like ' 'Gecko', - "IE 11.0 for Desktop Win7 64-bit") + "IE 11.0 for Desktop Win7 64-bit") ] return out diff --git a/qutebrowser/config/parsers/keyconf.py b/qutebrowser/config/parsers/keyconf.py index 7c7eb5554..c994913db 100644 --- a/qutebrowser/config/parsers/keyconf.py +++ b/qutebrowser/config/parsers/keyconf.py @@ -153,7 +153,7 @@ class KeyConfigParser(QObject): @cmdutils.register(instance='key-config', maxsplit=1, no_cmd_split=True, no_replace_variables=True) @cmdutils.argument('win_id', win_id=True) - @cmdutils.argument('command', completion=usertypes.Completion.command) + @cmdutils.argument('command', completion=usertypes.Completion.bind) def bind(self, key, win_id, command=None, *, mode='normal', force=False): """Bind a key to a command. @@ -335,6 +335,7 @@ class KeyConfigParser(QObject): def _validate_command(self, line): """Check if a given command is valid.""" + from qutebrowser.config import config if line == self.UNBOUND_COMMAND: return commands = line.split(';;') @@ -352,7 +353,8 @@ class KeyConfigParser(QObject): line)) commands = [c.split(maxsplit=1)[0].strip() for c in commands] for cmd in commands: - if cmd not in cmdutils.cmd_dict: + aliases = config.section('aliases') + if cmd not in cmdutils.cmd_dict and cmd not in aliases: raise KeyConfigError("Invalid command '{}'!".format(cmd)) def _read_command(self, line): @@ -422,8 +424,9 @@ class KeyConfigParser(QObject): def get_reverse_bindings_for(self, section): """Get a dict of commands to a list of bindings for the section.""" - cmd_to_keys = collections.defaultdict(list) + cmd_to_keys = {} for key, cmd in self.get_bindings_for(section).items(): + cmd_to_keys.setdefault(cmd, []) # put special bindings last if utils.is_special_key(key): cmd_to_keys[cmd].append(key) diff --git a/qutebrowser/javascript/.eslintrc.yaml b/qutebrowser/javascript/.eslintrc.yaml new file mode 100644 index 000000000..de3f9e6ef --- /dev/null +++ b/qutebrowser/javascript/.eslintrc.yaml @@ -0,0 +1,38 @@ +env: + browser: true + +parserOptions: + ecmaVersion: 3 + +extends: + "eslint:all" + +rules: + strict: ["error", "global"] + one-var: "off" + padded-blocks: ["error", "never"] + space-before-function-paren: ["error", "never"] + no-underscore-dangle: "off" + no-var: "off" + vars-on-top: "off" + newline-after-var: "off" + camelcase: "off" + require-jsdoc: "off" + func-style: ["error", "declaration"] + newline-before-return: "off" + init-declarations: "off" + no-plusplus: "off" + no-extra-parens: off + id-length: ["error", {"exceptions": ["i", "k", "x", "y"]}] + object-shorthand: "off" + max-statements: ["error", {"max": 30}] + quotes: ["error", "double", {"avoidEscape": true}] + object-property-newline: ["error", {"allowMultiplePropertiesPerLine": true}] + comma-dangle: ["error", "always-multiline"] + no-magic-numbers: "off" + no-undefined: "off" + wrap-iife: ["error", "inside"] + func-names: "off" + sort-keys: "off" + no-warning-comments: "off" + max-len: ["error", {"ignoreUrls": true}] diff --git a/qutebrowser/javascript/position_caret.js b/qutebrowser/javascript/position_caret.js index 5ffd882de..09d2301ad 100644 --- a/qutebrowser/javascript/position_caret.js +++ b/qutebrowser/javascript/position_caret.js @@ -1,6 +1,6 @@ /** * Copyright 2015 Artur Shaik -* Copyright 2015 Florian Bruhin (The Compiler) +* Copyright 2015-2016 Florian Bruhin (The Compiler) * * This file is part of qutebrowser. * @@ -32,79 +32,84 @@ "use strict"; -function isElementInViewport(node) { - var i; - var boundingRect = (node.getClientRects()[0] || - node.getBoundingClientRect()); - if (boundingRect.width <= 1 && boundingRect.height <= 1) { - var rects = node.getClientRects(); - for (i = 0; i < rects.length; i++) { - if (rects[i].width > rects[0].height && - rects[i].height > rects[0].height) { - boundingRect = rects[i]; +(function() { + // FIXME:qtwebengine integrate this with other window._qutebrowser code? + function isElementInViewport(node) { // eslint-disable-line complexity + var i; + var boundingRect = (node.getClientRects()[0] || + node.getBoundingClientRect()); + + if (boundingRect.width <= 1 && boundingRect.height <= 1) { + var rects = node.getClientRects(); + for (i = 0; i < rects.length; i++) { + if (rects[i].width > rects[0].height && + rects[i].height > rects[0].height) { + boundingRect = rects[i]; + } } } + if (boundingRect === undefined) { + return null; + } + if (boundingRect.top > innerHeight || boundingRect.left > innerWidth) { + return null; + } + if (boundingRect.width <= 1 || boundingRect.height <= 1) { + var children = node.children; + var visibleChildNode = false; + for (i = 0; i < children.length; ++i) { + boundingRect = (children[i].getClientRects()[0] || + children[i].getBoundingClientRect()); + if (boundingRect.width > 1 && boundingRect.height > 1) { + visibleChildNode = true; + break; + } + } + if (visibleChildNode === false) { + return null; + } + } + if (boundingRect.top + boundingRect.height < 10 || + boundingRect.left + boundingRect.width < -10) { + return null; + } + var computedStyle = window.getComputedStyle(node, null); + if (computedStyle.visibility !== "visible" || + computedStyle.display === "none" || + node.hasAttribute("disabled") || + parseInt(computedStyle.width, 10) === 0 || + parseInt(computedStyle.height, 10) === 0) { + return null; + } + return boundingRect.top >= -20; } - if (boundingRect === undefined) { - return null; - } - if (boundingRect.top > innerHeight || boundingRect.left > innerWidth) { - return null; - } - if (boundingRect.width <= 1 || boundingRect.height <= 1) { - var children = node.children; - var visibleChildNode = false; - var l = children.length; - for (i = 0; i < l; ++i) { - boundingRect = (children[i].getClientRects()[0] || - children[i].getBoundingClientRect()); - if (boundingRect.width > 1 && boundingRect.height > 1) { - visibleChildNode = true; + + function positionCaret() { + var walker = document.createTreeWalker(document.body, 4, null); + var node; + var textNodes = []; + var el; + while ((node = walker.nextNode())) { + if (node.nodeType === 3 && node.data.trim() !== "") { + textNodes.push(node); + } + } + for (var i = 0; i < textNodes.length; i++) { + var element = textNodes[i].parentElement; + if (isElementInViewport(element.parentElement)) { + el = element; break; } } - if (visibleChildNode === false) { - return null; + if (el !== undefined) { + var range = document.createRange(); + range.setStart(el, 0); + range.setEnd(el, 0); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); } } - if (boundingRect.top + boundingRect.height < 10 || - boundingRect.left + boundingRect.width < -10) { - return null; - } - var computedStyle = window.getComputedStyle(node, null); - if (computedStyle.visibility !== 'visible' || - computedStyle.display === 'none' || - node.hasAttribute('disabled') || - parseInt(computedStyle.width, 10) === 0 || - parseInt(computedStyle.height, 10) === 0) { - return null; - } - return boundingRect.top >= -20; -} -(function() { - var walker = document.createTreeWalker(document.body, 4, null); - var node; - var textNodes = []; - var el; - while ((node = walker.nextNode())) { - if (node.nodeType === 3 && node.data.trim() !== '') { - textNodes.push(node); - } - } - for (var i = 0; i < textNodes.length; i++) { - var element = textNodes[i].parentElement; - if (isElementInViewport(element.parentElement)) { - el = element; - break; - } - } - if (el !== undefined) { - var range = document.createRange(); - range.setStart(el, 0); - range.setEnd(el, 0); - var sel = window.getSelection(); - sel.removeAllRanges(); - sel.addRange(range); - } + positionCaret(); })(); diff --git a/qutebrowser/javascript/scroll.js b/qutebrowser/javascript/scroll.js index b948e3d8b..67ec2b8fd 100644 --- a/qutebrowser/javascript/scroll.js +++ b/qutebrowser/javascript/scroll.js @@ -17,51 +17,74 @@ * along with qutebrowser. If not, see . */ -function _qutebrowser_scroll_to_perc(x, y) { - var elem = document.documentElement; - var x_px = window.scrollX; - var y_px = window.scrollY; +"use strict"; - if (x !== undefined) { - x_px = (elem.scrollWidth - elem.clientWidth) / 100 * x; - } +window._qutebrowser.scroll = (function() { + var funcs = {}; - if (y !== undefined) { - y_px = (elem.scrollHeight - elem.clientHeight) / 100 * y; - } + funcs.to_perc = function(x, y) { + var elem = document.documentElement; + var x_px = window.scrollX; + var y_px = window.scrollY; - window.scroll(x_px, y_px); -} + if (x !== undefined) { + x_px = (elem.scrollWidth - window.innerWidth) / 100 * x; + } -function _qutebrowser_scroll_delta_page(x, y) { - var dx = document.documentElement.clientWidth * x; - var dy = document.documentElement.clientHeight * y; - window.scrollBy(dx, dy); -} + if (y !== undefined) { + y_px = (elem.scrollHeight - window.innerHeight) / 100 * y; + } -function _qutebrowser_scroll_pos() { - var elem = document.documentElement; - var dx = (elem.scrollWidth - elem.clientWidth); - var dy = (elem.scrollHeight - elem.clientHeight); + /* + console.log(JSON.stringify({ + "x": x, + "window.scrollX": window.scrollX, + "window.innerWidth": window.innerWidth, + "elem.scrollWidth": elem.scrollWidth, + "x_px": x_px, + "y": y, + "window.scrollY": window.scrollY, + "window.innerHeight": window.innerHeight, + "elem.scrollHeight": elem.scrollHeight, + "y_px": y_px, + })); + */ - var perc_x, perc_y; + window.scroll(x_px, y_px); + }; - if (dx === 0) { - perc_x = 0; - } else { - perc_x = 100 / dx * window.scrollX; - } + funcs.delta_page = function(x, y) { + var dx = window.innerWidth * x; + var dy = window.innerHeight * y; + window.scrollBy(dx, dy); + }; - if (dy === 0) { - perc_y = 0; - } else { - perc_y = 100 / dy * window.scrollY; - } + funcs.pos = function() { + var elem = document.documentElement; + var dx = elem.scrollWidth - window.innerWidth; + var dy = elem.scrollHeight - window.innerHeight; + var perc_x, perc_y; - var pos_perc = {'x': perc_x, 'y': perc_y}; - var pos_px = {'x': window.scrollX, 'y': window.scrollY}; - var pos = {'perc': pos_perc, 'px': pos_px}; + if (dx === 0) { + perc_x = 0; + } else { + perc_x = 100 / dx * window.scrollX; + } - // console.log(JSON.stringify(pos)); - return pos; -} + if (dy === 0) { + perc_y = 0; + } else { + perc_y = 100 / dy * window.scrollY; + } + + var pos = { + "perc": {"x": perc_x, "y": perc_y}, + "px": {"x": window.scrollX, "y": window.scrollY}, + }; + + // console.log(JSON.stringify(pos)); + return pos; + }; + + return funcs; +})(); diff --git a/qutebrowser/javascript/webelem.js b/qutebrowser/javascript/webelem.js index 9163d6727..6de878c74 100644 --- a/qutebrowser/javascript/webelem.js +++ b/qutebrowser/javascript/webelem.js @@ -17,60 +17,135 @@ * along with qutebrowser. If not, see . */ +"use strict"; -document._qutebrowser_elements = []; +window._qutebrowser.webelem = (function() { + var funcs = {}; + var elements = []; + function serialize_elem(elem) { + if (!elem) { + return null; + } -function _qutebrowser_serialize_elem(elem, id) { - var out = { - "id": id, - "text": elem.text, - "tag_name": elem.tagName, - "outer_xml": elem.outerHTML + var id = elements.length; + elements[id] = elem; + + var out = { + "id": id, + "text": elem.text, + "tag_name": elem.tagName, + "outer_xml": elem.outerHTML, + "rects": [], // Gets filled up later + }; + + var attributes = {}; + for (var i = 0; i < elem.attributes.length; ++i) { + var attr = elem.attributes[i]; + attributes[attr.name] = attr.value; + } + out.attributes = attributes; + + var client_rects = elem.getClientRects(); + for (var k = 0; k < client_rects.length; ++k) { + var rect = client_rects[k]; + out.rects.push({ + "top": rect.top, + "right": rect.right, + "bottom": rect.bottom, + "left": rect.left, + "height": rect.height, + "width": rect.width, + }); + } + + // console.log(JSON.stringify(out)); + + return out; + } + + function is_visible(elem) { + // FIXME:qtwebengine Handle frames and iframes + + // Adopted from vimperator: + // https://github.com/vimperator/vimperator-labs/blob/vimperator-3.14.0/common/content/hints.js#L259-L285 + // FIXME:qtwebengine we might need something more sophisticated like + // the cVim implementation here? + // https://github.com/1995eaton/chromium-vim/blob/1.2.85/content_scripts/dom.js#L74-L134 + + var win = elem.ownerDocument.defaultView; + var rect = elem.getBoundingClientRect(); + + if (!rect || + rect.top > window.innerHeight || + rect.bottom < 0 || + rect.left > window.innerWidth || + rect.right < 0) { + return false; + } + + rect = elem.getClientRects()[0]; + if (!rect) { + return false; + } + + var style = win.getComputedStyle(elem, null); + // FIXME:qtwebengine do we need this handling? + // visibility and display style are misleading for area tags and they + // get "display: none" by default. + // See https://github.com/vimperator/vimperator-labs/issues/236 + if (elem.nodeName.toLowerCase() !== "area" && ( + style.getPropertyValue("visibility") !== "visible" || + style.getPropertyValue("display") === "none")) { + return false; + } + + return true; + } + + funcs.find_all = function(selector, only_visible) { + var elems = document.querySelectorAll(selector); + var out = []; + + for (var i = 0; i < elems.length; ++i) { + if (!only_visible || is_visible(elems[i])) { + out.push(serialize_elem(elems[i])); + } + } + + return out; }; - var attributes = {}; - for (var i = 0; i < elem.attributes.length; ++i) { - attr = elem.attributes[i]; - attributes[attr.name] = attr.value; - } - out["attributes"] = attributes; + funcs.focus_element = function() { + var elem = document.activeElement; - // console.log(JSON.stringify(out)); + if (!elem || elem === document.body) { + // "When there is no selection, the active element is the page's + // or null." + return null; + } - return out; -} + return serialize_elem(elem); + }; + funcs.set_text = function(id, text) { + elements[id].value = text; + }; -function _qutebrowser_find_all_elements(selector) { - var elems = document.querySelectorAll(selector); - var out = []; - var id = document._qutebrowser_elements.length; + funcs.element_at_pos = function(x, y) { + // FIXME:qtwebengine + // If the element at the specified point belongs to another document + // (for example, an iframe's subdocument), the subdocument's parent + // element is returned (the iframe itself). - for (var i = 0; i < elems.length; ++i) { - var elem = elems[i]; - out.push(_qutebrowser_serialize_elem(elem, id)); - document._qutebrowser_elements[id] = elem; - id++; - } + var elem = document.elementFromPoint(x, y); + return serialize_elem(elem); + }; - return out; -} + funcs.element_by_id = function(id) { + var elem = document.getElementById(id); + return serialize_elem(elem); + }; - -function _qutebrowser_focus_element() { - var elem = document.activeElement; - if (!elem || elem === document.body) { - // "When there is no selection, the active element is the page's - // or null." - return null; - } - - var id = document._qutebrowser_elements.length; - return _qutebrowser_serialize_elem(elem, id); -} - - -function _qutebrowser_get_element(id) { - return document._qutebrowser_elements[id]; -} + return funcs; +})(); diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 887f22515..8578229be 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -131,7 +131,9 @@ class BaseKeyParser(QObject): except KeyError: self._debug_log("No special binding found for {}.".format(binding)) return False - self.execute(cmdstr, self.Type.special) + count, _command = self._split_count() + self.execute(cmdstr, self.Type.special, count) + self.clear_keystring() return True def _split_count(self): @@ -193,7 +195,7 @@ class BaseKeyParser(QObject): if match == self.Match.definitive: self._debug_log("Definitive match for '{}'.".format( self._keystring)) - self._keystring = '' + self.clear_keystring() self.execute(binding, self.Type.chain, count) elif match == self.Match.ambiguous: self._debug_log("Ambiguous match for '{}'.".format( @@ -205,7 +207,7 @@ class BaseKeyParser(QObject): elif match == self.Match.none: self._debug_log("Giving up with '{}', no matches".format( self._keystring)) - self._keystring = '' + self.clear_keystring() else: raise AssertionError("Invalid match value {!r}".format(match)) return match @@ -271,7 +273,7 @@ class BaseKeyParser(QObject): time = config.get('input', 'timeout') if time == 0: # execute immediately - self._keystring = '' + self.clear_keystring() self.execute(binding, self.Type.chain, count) else: # execute in `time' ms @@ -289,8 +291,7 @@ class BaseKeyParser(QObject): command/count: As if passed to self.execute() """ self._debug_log("Executing delayed command now!") - self._keystring = '' - self.keystring_updated.emit(self._keystring) + self.clear_keystring() self.execute(command, self.Type.chain, count) def handle(self, e): @@ -307,7 +308,9 @@ class BaseKeyParser(QObject): if handled or not self._supports_chains: return handled match = self._handle_single_key(e) - self.keystring_updated.emit(self._keystring) + # don't emit twice if the keystring was cleared in self.clear_keystring + if self._keystring: + self.keystring_updated.emit(self._keystring) return match != self.Match.none def read_config(self, modename=None): @@ -366,6 +369,8 @@ class BaseKeyParser(QObject): def clear_keystring(self): """Clear the currently entered key sequence.""" - self._debug_log("discarding keystring '{}'.".format(self._keystring)) - self._keystring = '' - self.keystring_updated.emit(self._keystring) + if self._keystring: + self._debug_log("discarding keystring '{}'.".format( + self._keystring)) + self._keystring = '' + self.keystring_updated.emit(self._keystring) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 9aa492062..71a83b9a3 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -282,6 +282,9 @@ class ModeManager(QObject): raise NotInModeError("Not in mode {}!".format(mode)) log.modes.debug("Leaving mode {}{}".format( mode, '' if reason is None else ' (reason: {})'.format(reason))) + # leaving a mode implies clearing keychain, see + # https://github.com/The-Compiler/qutebrowser/issues/1805 + self.clear_keychain() self.mode = usertypes.KeyMode.normal self.left.emit(mode, self.mode, self._win_id) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 4876bbce6..aa788ddc3 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -25,7 +25,6 @@ Module attributes: from PyQt5.QtCore import pyqtSlot, Qt -from qutebrowser.utils import message from qutebrowser.config import config from qutebrowser.keyinput import keyparser from qutebrowser.utils import usertypes, log, objreg, utils @@ -70,9 +69,6 @@ class NormalKeyParser(keyparser.CommandKeyParser): self._debug_log("Ignoring key '{}', because the normal mode is " "currently inhibited.".format(txt)) return self.Match.none - if not self._keystring and any(txt == c for c in STARTCHARS): - message.set_cmd_text(self._win_id, txt) - return self.Match.definitive match = super()._handle_single_key(e) if match == self.Match.partial: timeout = config.get('input', 'partial-timeout') @@ -189,7 +185,7 @@ class HintKeyParser(keyparser.CommandKeyParser): return True else: return super()._handle_special_key(e) - elif config.get('hints', 'mode') != 'number': + elif hintmanager.current_mode() != 'number': return super()._handle_special_key(e) elif not e.text(): return super()._handle_special_key(e) @@ -231,7 +227,7 @@ class HintKeyParser(keyparser.CommandKeyParser): if keytype == self.Type.chain: hintmanager = objreg.get('hintmanager', scope='tab', window=self._win_id, tab='current') - hintmanager.fire(cmdstr) + hintmanager.handle_partial_key(cmdstr) else: # execute as command super().execute(cmdstr, keytype, count) diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 8198ba8cd..6bb35735c 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -54,38 +54,62 @@ def get_window(via_ipc, force_window=False, force_tab=False, """ if force_window and force_tab: raise ValueError("force_window and force_tab are mutually exclusive!") + if not via_ipc: # Initial main window return 0 - window_to_raise = None + + open_target = config.get('general', 'new-instance-open-target') + + # Apply any target overrides, ordered by precedence if force_target is not None: open_target = force_target - else: - open_target = config.get('general', 'new-instance-open-target') - if (open_target == 'window' or force_window) and not force_tab: + if force_window: + open_target = 'window' + if force_tab and open_target == 'window': + # Command sent via IPC + open_target = 'tab-silent' + + window = None + raise_window = False + + # Try to find the existing tab target if opening in a tab + if open_target != 'window': + window = get_target_window() + raise_window = open_target not in ['tab-silent', 'tab-bg-silent'] + + # Otherwise, or if no window was found, create a new one + if window is None: window = MainWindow() window.show() - win_id = window.win_id - window_to_raise = window - else: - try: - window = objreg.last_window() - except objreg.NoWindow: - # There is no window left, so we open a new one - window = MainWindow() - window.show() - win_id = window.win_id - window_to_raise = window - win_id = window.win_id - if open_target not in ['tab-silent', 'tab-bg-silent']: - window_to_raise = window - if window_to_raise is not None: - window_to_raise.setWindowState( - window.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) - window_to_raise.raise_() - window_to_raise.activateWindow() - QApplication.instance().alert(window_to_raise) - return win_id + raise_window = True + + if raise_window: + window.setWindowState(window.windowState() & ~Qt.WindowMinimized) + window.setWindowState(window.windowState() | Qt.WindowActive) + window.raise_() + window.activateWindow() + QApplication.instance().alert(window) + + return window.win_id + + +def get_target_window(): + """Get the target window for new tabs, or None if none exist.""" + try: + win_mode = config.get('general', 'new-instance-open-target.window') + if win_mode == 'last-focused': + return objreg.last_focused_window() + elif win_mode == 'first-opened': + return objreg.window_by_index(0) + elif win_mode == 'last-opened': + return objreg.window_by_index(-1) + elif win_mode == 'last-visible': + return objreg.last_visible_window() + else: + raise ValueError("Invalid win_mode {}".format(win_mode)) + except objreg.NoWindow: + return None class MainWindow(QWidget): @@ -175,9 +199,6 @@ class MainWindow(QWidget): QTimer.singleShot(0, self._connect_resize_keyhint) objreg.get('config').changed.connect(self.on_config_changed) - if config.get('ui', 'hide-mouse-cursor'): - self.setCursor(Qt.BlankCursor) - objreg.get("app").new_window.emit(self) def _init_downloadmanager(self): @@ -457,8 +478,23 @@ class MainWindow(QWidget): self._downloadview.updateGeometry() self.tabbed_browser.tabBar().refresh() + def showEvent(self, e): + """Extend showEvent to register us as the last-visible-main-window. + + Args: + e: The QShowEvent + """ + super().showEvent(e) + objreg.register('last-visible-main-window', self, update=True) + def _do_close(self): """Helper function for closeEvent.""" + last_visible = objreg.get('last-visible-main-window') + if self is last_visible: + try: + objreg.delete('last-visible-main-window') + except KeyError: + pass objreg.get('session-manager').save_last_window_session() self._save_geometry() log.destroy.debug("Closing window {}".format(self.win_id)) diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index dcac67ad3..1f0055df1 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -34,7 +34,7 @@ from qutebrowser.utils import (log, usertypes, utils, qtutils, objreg, urlutils, message) -UndoEntry = collections.namedtuple('UndoEntry', ['url', 'history']) +UndoEntry = collections.namedtuple('UndoEntry', ['url', 'history', 'index']) class TabDeletedError(Exception): @@ -198,6 +198,7 @@ class TabbedBrowser(tabwidget.TabWidget): tab.window_close_requested.connect( functools.partial(self.on_window_close_requested, tab)) tab.new_tab_requested.connect(self.tabopen) + tab.add_history_item.connect(objreg.get('web-history').add_from_tab) def current_url(self): """Get the URL of the current tab. @@ -216,11 +217,12 @@ class TabbedBrowser(tabwidget.TabWidget): for tab in self.widgets(): self._remove_tab(tab) - def close_tab(self, tab): + def close_tab(self, tab, *, add_undo=True): """Close a tab. Args: tab: The QWebView to be closed. + add_undo: Whether the tab close can be undone. """ last_close = config.get('tabs', 'last-close') count = self.count() @@ -228,7 +230,7 @@ class TabbedBrowser(tabwidget.TabWidget): if last_close == 'ignore' and count == 1: return - self._remove_tab(tab) + self._remove_tab(tab, add_undo=add_undo) if count == 1: # We just closed the last tab above. if last_close == 'close': @@ -242,11 +244,12 @@ class TabbedBrowser(tabwidget.TabWidget): url = config.get('general', 'default-page') self.openurl(url, newtab=True) - def _remove_tab(self, tab): + def _remove_tab(self, tab, *, add_undo=True): """Remove a tab from the tab list and delete it properly. Args: tab: The QWebView to be closed. + add_undo: Whether the tab close can be undone. """ idx = self.indexOf(tab) if idx == -1: @@ -260,8 +263,9 @@ class TabbedBrowser(tabwidget.TabWidget): window=self._win_id) if tab.url().isValid(): history_data = tab.history.serialize() - entry = UndoEntry(tab.url(), history_data) - self._undo_stack.append(entry) + if add_undo: + entry = UndoEntry(tab.url(), history_data, idx) + self._undo_stack.append(entry) elif tab.url().isEmpty(): # There are some good reasons why a URL could be empty # (target="_blank" with a download, see [1]), so we silently ignore @@ -297,13 +301,13 @@ class TabbedBrowser(tabwidget.TabWidget): use_current_tab = (only_one_tab_open and no_history and last_close_url_used) - url, history_data = self._undo_stack.pop() + url, history_data, idx = self._undo_stack.pop() if use_current_tab: self.openurl(url, newtab=False) newtab = self.widget(0) else: - newtab = self.tabopen(url, background=False) + newtab = self.tabopen(url, background=False, idx=idx) newtab.history.deserialize(history_data) @@ -342,7 +346,7 @@ class TabbedBrowser(tabwidget.TabWidget): @pyqtSlot('QUrl') @pyqtSlot('QUrl', bool) - def tabopen(self, url=None, background=None, explicit=False): + def tabopen(self, url=None, background=None, explicit=False, idx=None): """Open a new tab with a given URL. Inner logic for open-tab and open-tab-bg. @@ -358,6 +362,7 @@ class TabbedBrowser(tabwidget.TabWidget): - Tabs from clicked links etc. are to the right of the current. - Explicitly opened tabs are at the very right. + idx: The index where the new tab should be opened. Return: The opened WebView instance. @@ -376,7 +381,8 @@ class TabbedBrowser(tabwidget.TabWidget): tab = browsertab.create(win_id=self._win_id, parent=self) self._connect_tab_signals(tab) - idx = self._get_new_tab_idx(explicit) + if idx is None: + idx = self._get_new_tab_idx(explicit) self.insertTab(idx, tab, "") if url is not None: diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index 537ff5ea4..dcf289452 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -66,6 +66,9 @@ class TabWidget(QTabWidget): @config.change_filter('tabs') def init_config(self): """Initialize attributes based on the config.""" + if self is None: # pragma: no cover + # WORKAROUND for PyQt 5.2 + return tabbar = self.tabBar() self.setMovable(config.get('tabs', 'movable')) self.setTabsClosable(False) @@ -103,7 +106,8 @@ class TabWidget(QTabWidget): fields['index'] = idx + 1 fmt = config.get('tabs', 'title-format') - self.tabBar().setTabText(idx, fmt.format(**fields)) + title = '' if fmt is None else fmt.format(**fields) + self.tabBar().setTabText(idx, title) def get_tab_fields(self, idx): """Get the tab field data.""" diff --git a/qutebrowser/misc/earlyinit.py b/qutebrowser/misc/earlyinit.py index 284478866..54900ffcb 100644 --- a/qutebrowser/misc/earlyinit.py +++ b/qutebrowser/misc/earlyinit.py @@ -60,11 +60,11 @@ def _missing_str(name, *, windows=None, pip=None, webengine=False): blocks.append('
'.join(lines)) if webengine: lines = [ - 'Note QtWebEngine is not available for some distributions ' + ('Note QtWebEngine is not available for some distributions ' '(like Debian/Ubuntu), so you need to start without ' - '--backend webengine there.', - 'QtWebEngine is currently unsupported with the OS X .app, see ' - 'https://github.com/The-Compiler/qutebrowser/issues/1692', + '--backend webengine there.'), + ('QtWebEngine is currently unsupported with the OS X .app, see ' + 'https://github.com/The-Compiler/qutebrowser/issues/1692'), ] else: lines = ['If you installed a qutebrowser package for your ' @@ -301,6 +301,13 @@ def init_log(args): log.init.debug("Log initialized.") +def check_optimize_flag(): + from qutebrowser.utils import log + if sys.flags.optimize >= 2: + log.init.warning("Running on optimize level higher than 1, " + "unexpected behavior may occur.") + + def earlyinit(args): """Do all needed early initialization. @@ -327,3 +334,4 @@ def earlyinit(args): remove_inputhook() check_libraries(args) check_ssl_support() + check_optimize_flag() diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py index 63a5718c6..ba21e541a 100644 --- a/qutebrowser/misc/miscwidgets.py +++ b/qutebrowser/misc/miscwidgets.py @@ -47,7 +47,7 @@ class MinimalLineEditMixin: if e.key() == Qt.Key_Insert and e.modifiers() == Qt.ShiftModifier: try: text = utils.get_clipboard(selection=True) - except utils.SelectionUnsupportedError: + except utils.ClipboardError: pass else: e.accept() diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 9622db367..4d9ebb4a3 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -22,6 +22,7 @@ import functools import types import traceback +import logging try: import hunter @@ -247,3 +248,40 @@ def log_capacity(capacity: int): raise cmdexc.CommandError("Can't set a negative log capacity!") else: log.ram_handler.change_log_capacity(capacity) + + +@cmdutils.register(debug=True) +@cmdutils.argument('level', choices=sorted( + (level.lower() for level in log.LOG_LEVELS), + key=lambda e: log.LOG_LEVELS[e.upper()])) +def debug_log_level(level: str): + """Change the log level for console logging. + + Args: + level: The log level to set. + """ + log.console_handler.setLevel(log.LOG_LEVELS[level.upper()]) + + +@cmdutils.register(debug=True) +def debug_log_filter(filters: str): + """Change the log filter for console logging. + + Args: + filters: A comma separated list of logger names. + """ + if set(filters.split(',')).issubset(log.LOGGER_NAMES): + log.console_filter.names = filters.split(',') + else: + raise cmdexc.CommandError("filters: Invalid value {} - expected one " + "of: {}".format(filters, + ', '.join(log.LOGGER_NAMES))) + + +@cmdutils.register() +@cmdutils.argument('current_win_id', win_id=True) +def window_only(current_win_id): + """Close all windows except for the current one.""" + for win_id, window in objreg.window_registry.items(): + if win_id != current_win_id: + window.close() diff --git a/qutebrowser/utils/docutils.py b/qutebrowser/utils/docutils.py index d93e7564d..40cb0cb70 100644 --- a/qutebrowser/utils/docutils.py +++ b/qutebrowser/utils/docutils.py @@ -26,7 +26,7 @@ import os.path import collections import qutebrowser -from qutebrowser.utils import usertypes +from qutebrowser.utils import usertypes, log, utils def is_git_repo(): @@ -98,6 +98,15 @@ class DocstringParser: self.State.arg_inside: self._parse_arg_inside, self.State.misc: self._skip, } + if doc is None: + if sys.flags.optimize < 2: + log.commands.warning( + "Function {}() from {} has no docstring".format( + utils.qualname(func), + inspect.getsourcefile(func))) + self.long_desc = "" + self.short_desc = "" + return for line in doc.splitlines(): handler = handlers[self._state] stop = handler(line) diff --git a/qutebrowser/utils/javascript.py b/qutebrowser/utils/javascript.py index a40fa7075..4fc7e546c 100644 --- a/qutebrowser/utils/javascript.py +++ b/qutebrowser/utils/javascript.py @@ -20,9 +20,6 @@ """Utilities related to javascript interaction.""" -from qutebrowser.utils import utils - - def string_escape(text): """Escape values special to javascript in strings. @@ -55,6 +52,8 @@ def _convert_js_arg(arg): return 'undefined' elif isinstance(arg, str): return '"{}"'.format(string_escape(arg)) + elif isinstance(arg, bool): + return str(arg).lower() elif isinstance(arg, (int, float)): return str(arg) else: @@ -62,11 +61,12 @@ def _convert_js_arg(arg): arg, type(arg).__name__)) -def assemble(name, function, *args): +def assemble(module, function, *args): """Assemble a javascript file and a function call.""" - code = "{code}\n_qutebrowser_{function}({args});".format( - code=utils.read_file('javascript/{}.js'.format(name)), - function=function, - args=', '.join(_convert_js_arg(arg) for arg in args), - ) + js_args = ', '.join(_convert_js_arg(arg) for arg in args) + if module == 'window': + parts = ['window', function] + else: + parts = ['window', '_qutebrowser', module, function] + code = '"use strict";\n{}({});'.format('.'.join(parts), js_args) return code diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 177c263f8..1ee91bad5 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -87,6 +87,15 @@ LOG_LEVELS = { 'CRITICAL': logging.CRITICAL, } +LOGGER_NAMES = [ + 'statusbar', 'completion', 'init', 'url', + 'destroy', 'modes', 'webview', 'misc', + 'mouse', 'procs', 'hints', 'keyboard', + 'commands', 'signals', 'downloads', + 'js', 'qt', 'rfc6266', 'ipc', 'shlexer', + 'save', 'message', 'config', 'sessions' +] + def vdebug(self, msg, *args, **kwargs): """Log with a VDEBUG level. @@ -131,6 +140,8 @@ sessions = logging.getLogger('sessions') ram_handler = None +console_handler = None +console_filter = None def stub(suffix=''): @@ -149,6 +160,7 @@ class CriticalQtWarning(Exception): def init_log(args): """Init loggers based on the argparse namespace passed.""" + global console level = args.loglevel.upper() try: numeric_level = getattr(logging, level) @@ -161,9 +173,11 @@ def init_log(args): console, ram = _init_handlers(numeric_level, args.color, args.force_color, args.json_logging, args.loglines) root = logging.getLogger() + global console_filter if console is not None: if args.logfilter is not None: - console.addFilter(LogFilter(args.logfilter.split(','))) + console_filter = LogFilter(args.logfilter.split(',')) + console.addFilter(console_filter) root.addHandler(console) if ram is not None: root.addHandler(ram) @@ -175,6 +189,10 @@ def init_log(args): _log_inited = True +def change(filters): + console.addFilter(LogFilter(filters.split(','))) + + def _init_py_warnings(): """Initialize Python warning handling.""" warnings.simplefilter('default') @@ -210,6 +228,7 @@ def _init_handlers(level, color, force_color, json_logging, ram_capacity): json_logging: Output log lines in JSON (this disables all colors). """ global ram_handler + global console_handler console_fmt, ram_fmt, html_fmt, use_colorama = _init_formatters( level, color, force_color, json_logging) @@ -448,16 +467,16 @@ class LogFilter(logging.Filter): def __init__(self, names): super().__init__() - self._names = names + self.names = names def filter(self, record): """Determine if the specified record is to be logged.""" - if self._names is None: + if self.names is None: return True if record.levelno > logging.DEBUG: # More important than DEBUG, so we won't filter at all return True - for name in self._names: + for name in self.names: if record.name == name: return True elif not record.name.startswith(name): diff --git a/qutebrowser/utils/objreg.py b/qutebrowser/utils/objreg.py index 0dc6ec68c..d7728379e 100644 --- a/qutebrowser/utils/objreg.py +++ b/qutebrowser/utils/objreg.py @@ -94,8 +94,15 @@ class ObjectRegistry(collections.UserDict): def _disconnect_destroyed(self, name): """Disconnect the destroyed slot if it was connected.""" - if name in self._partial_objs: - func = self._partial_objs[name] + try: + partial_objs = self._partial_objs + except AttributeError: + # This sometimes seems to happen on Travis during + # test_history.test_adding_item_during_async_read + # and I have no idea why... + return + if name in partial_objs: + func = partial_objs[name] try: self[name].destroyed.disconnect(func) except (RuntimeError, TypeError): @@ -106,7 +113,7 @@ class ObjectRegistry(collections.UserDict): # pyqtSignal must be bound to a QObject" instead: # https://github.com/The-Compiler/qutebrowser/issues/257 pass - del self._partial_objs[name] + del partial_objs[name] def on_destroyed(self, name): """Schedule removing of a destroyed QObject. @@ -121,6 +128,11 @@ class ObjectRegistry(collections.UserDict): def _on_destroyed(self, name): """Remove a destroyed QObject.""" log.destroy.debug("removed: {}".format(name)) + if not hasattr(self, 'data'): + # This sometimes seems to happen on Travis during + # test_history.test_adding_item_during_async_read + # and I have no idea why... + return try: del self[name] del self._partial_objs[name] @@ -178,10 +190,7 @@ def _get_window_registry(window): app = get('app') win = app.activeWindow() elif window == 'last-focused': - try: - win = get('last-focused-main-window') - except KeyError: - win = last_window() + win = last_focused_window() else: win = window_registry[window] except (KeyError, NoWindow): @@ -276,10 +285,26 @@ def dump_objects(): return lines -def last_window(): - """Get the last opened window object.""" +def last_visible_window(): + """Get the last visible window, or the last focused window if none.""" + try: + return get('last-visible-main-window') + except KeyError: + return last_focused_window() + + +def last_focused_window(): + """Get the last focused window, or the last window if none.""" + try: + return get('last-focused-main-window') + except KeyError: + return window_by_index(-1) + + +def window_by_index(idx): + """Get the Nth opened window object.""" if not window_registry: raise NoWindow() else: - key = sorted(window_registry)[-1] + key = sorted(window_registry)[idx] return window_registry[key] diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 2fe465aa4..7e1b8ea9e 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -499,7 +499,7 @@ class IncDecError(Exception): return '{}: {}'.format(self.msg, self.url.toString()) -def _get_incdec_value(match, incdec, url): +def _get_incdec_value(match, incdec, url, count): """Get an incremented/decremented URL based on a URL match.""" pre, zeroes, number, post = match.groups() # This should always succeed because we match \d+ @@ -507,9 +507,9 @@ def _get_incdec_value(match, incdec, url): if incdec == 'decrement': if val <= 0: raise IncDecError("Can't decrement {}!".format(val), url) - val -= 1 + val -= count elif incdec == 'increment': - val += 1 + val += count else: raise ValueError("Invalid value {} for indec!".format(incdec)) if zeroes: @@ -521,12 +521,13 @@ def _get_incdec_value(match, incdec, url): return ''.join([pre, zeroes, str(val), post]) -def incdec_number(url, incdec, segments=None): +def incdec_number(url, incdec, count=1, segments=None): """Find a number in the url and increment or decrement it. Args: url: The current url incdec: Either 'increment' or 'decrement' + count: The number to increment or decrement by segments: A set of URL segments to search. Valid segments are: 'host', 'path', 'query', 'anchor'. Default: {'path', 'query'} @@ -566,7 +567,7 @@ def incdec_number(url, incdec, segments=None): if not match: continue - setter(_get_incdec_value(match, incdec, url)) + setter(_get_incdec_value(match, incdec, url, count)) return url raise IncDecError("No number found in URL!", url) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 1cd1f0385..26f9202ff 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -226,7 +226,8 @@ PromptMode = enum('PromptMode', ['yesno', 'text', 'user_pwd', 'alert', # Where to open a clicked link. -ClickTarget = enum('ClickTarget', ['normal', 'tab', 'tab_bg', 'window']) +ClickTarget = enum('ClickTarget', ['normal', 'tab', 'tab_bg', 'window', + 'hover']) # Key input modes @@ -238,7 +239,8 @@ KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt', # Available command completions Completion = enum('Completion', ['command', 'section', 'option', 'value', 'helptopic', 'quickmark_by_name', - 'bookmark_by_url', 'url', 'tab', 'sessions']) + 'bookmark_by_url', 'url', 'tab', 'sessions', + 'bind']) # Exit statuses for errors. Needs to be an int for sys.exit. diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index fd8419d12..72d6f83e2 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -43,11 +43,21 @@ fake_clipboard = None log_clipboard = False -class SelectionUnsupportedError(Exception): +class ClipboardError(Exception): + + """Raised if the clipboard contents are unavailable for some reason.""" + + +class SelectionUnsupportedError(ClipboardError): """Raised if [gs]et_clipboard is used and selection=True is unsupported.""" +class ClipboardEmptyError(ClipboardError): + + """Raised if get_clipboard is used and the clipboard is empty.""" + + def elide(text, length): """Elide text so it uses a maximum of length chars.""" if length < 1: @@ -810,6 +820,11 @@ def get_clipboard(selection=False): mode = QClipboard.Selection if selection else QClipboard.Clipboard data = QApplication.clipboard().text(mode=mode) + target = "Primary selection" if selection else "Clipboard" + if not data.strip(): + raise ClipboardEmptyError("{} is empty.".format(target)) + log.misc.debug("{} contained: {!r}".format(target, data)) + return data diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 49420adfe..52b6d8775 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -29,10 +29,14 @@ import importlib import collections from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR, qVersion -from PyQt5.QtWebKit import qWebKitVersion from PyQt5.QtNetwork import QSslSocket from PyQt5.QtWidgets import QApplication +try: + from PyQt5.QtWebKit import qWebKitVersion +except ImportError: # pragma: no cover + qWebKitVersion = None + import qutebrowser from qutebrowser.utils import log, utils from qutebrowser.browser import pdfjs @@ -225,9 +229,14 @@ def version(): lines += _module_versions() + lines += ['pdf.js: {}'.format(_pdfjs_version())] + + if qWebKitVersion is None: + lines.append('Webkit: no') + else: + lines.append('Webkit: {}'.format(qWebKitVersion())) + lines += [ - 'pdf.js: {}'.format(_pdfjs_version()), - 'Webkit: {}'.format(qWebKitVersion()), 'Harfbuzz: {}'.format(os.environ.get('QT_HARFBUZZ', 'system')), 'SSL: {}'.format(QSslSocket.sslLibraryVersionString()), '', diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 98d069a0a..803a0a251 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -52,7 +52,9 @@ PERFECT_FILES = [ ('tests/unit/browser/webkit/test_cookies.py', 'qutebrowser/browser/webkit/cookies.py'), ('tests/unit/browser/webkit/test_history.py', - 'qutebrowser/browser/webkit/history.py'), + 'qutebrowser/browser/history.py'), + ('tests/unit/browser/webkit/test_history.py', + 'qutebrowser/browser/webkit/webkithistory.py'), ('tests/unit/browser/webkit/test_tabhistory.py', 'qutebrowser/browser/webkit/tabhistory.py'), ('tests/unit/browser/webkit/http/test_http.py', diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 723b0f424..240258297 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -56,21 +56,19 @@ def whitelist_generator(): yield 'qutebrowser.mainwindow.statusbar.url.UrlText.urltype' # Not used yet, but soon (or when debugging) - yield 'qutebrowser.config.configtypes.Regex' yield 'qutebrowser.utils.debug.log_events' yield 'qutebrowser.utils.debug.log_signals' yield 'qutebrowser.utils.debug.qflags_key' yield 'qutebrowser.utils.qtutils.QtOSError.qt_errno' - yield 'qutebrowser.utils.usertypes.NeighborList.firstitem' yield 'scripts.utils.bg_colors' - yield 'scripts.utils.print_subtitle' + yield 'qutebrowser.browser.webelem.AbstractWebElement.style_property' + yield 'qutebrowser.config.configtypes.Float' # Qt attributes yield 'PyQt5.QtWebKit.QWebPage.ErrorPageExtensionReturn().baseUrl' yield 'PyQt5.QtWebKit.QWebPage.ErrorPageExtensionReturn().content' yield 'PyQt5.QtWebKit.QWebPage.ErrorPageExtensionReturn().encoding' yield 'PyQt5.QtWebKit.QWebPage.ErrorPageExtensionReturn().fileNames' - yield 'PyQt5.QtGui.QAbstractTextDocumentLayout.PaintContext().clip' yield 'PyQt5.QtWidgets.QStyleOptionViewItem.backgroundColor' # qute:... handlers @@ -81,9 +79,7 @@ def whitelist_generator(): yield ('qutebrowser.completion.models.sortfilter.CompletionFilterModel().' 'lessThan') yield 'qutebrowser.utils.jinja.Loader.get_source' - yield 'qutebrowser.utils.log.VDEBUG' yield 'qutebrowser.utils.log.QtWarningFilter.filter' - yield 'logging.LogRecord.log_color' yield 'qutebrowser.browser.pdfjs.is_available' # vulture doesn't notice the hasattr() and thus thinks netrc_used is unused # in NetworkManager.on_authentication_required diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index 84b774a03..6a29c3410 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -37,11 +37,18 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, # We import qutebrowser.app so all @cmdutils-register decorators are run. import qutebrowser.app from scripts import asciidoc2html, utils -from qutebrowser import qutebrowser +from qutebrowser import qutebrowser, commands from qutebrowser.commands import cmdutils, argparser from qutebrowser.config import configdata from qutebrowser.utils import docutils, usertypes +FILE_HEADER = """ +// DO NOT EDIT THIS FILE DIRECTLY! +// It is autogenerated from docstrings by running: +// $ python3 scripts/dev/src2asciidoc.py + +""".lstrip() + class UsageFormatter(argparse.HelpFormatter): @@ -312,18 +319,22 @@ def _format_action(action): def generate_commands(filename): """Generate the complete commands section.""" with _open_file(filename) as f: - f.write("= Commands\n") + f.write(FILE_HEADER) + f.write("= Commands\n\n") + f.write(commands.__doc__) normal_cmds = [] hidden_cmds = [] debug_cmds = [] for name, cmd in cmdutils.cmd_dict.items(): if name in cmdutils.aliases: continue + if cmd.deprecated: + continue if cmd.hide: hidden_cmds.append((name, cmd)) elif cmd.debug: debug_cmds.append((name, cmd)) - elif not cmd.deprecated: + else: normal_cmds.append((name, cmd)) normal_cmds.sort() hidden_cmds.sort() @@ -395,6 +406,7 @@ def _generate_setting_section(f, sectname, sect): def generate_settings(filename): """Generate the complete settings section.""" with _open_file(filename) as f: + f.write(FILE_HEADER) f.write("= Settings\n") f.write(_get_setting_quickref() + "\n") for sectname, sect in configdata.DATA.items(): diff --git a/scripts/dev/ua_fetch.py b/scripts/dev/ua_fetch.py index 788267eda..f37797041 100644 --- a/scripts/dev/ua_fetch.py +++ b/scripts/dev/ua_fetch.py @@ -105,7 +105,7 @@ def main(): tab = " " print(tab + "def complete(self):") print((2 * tab) + "\"\"\"Complete a list of common user agents.\"\"\"") - print((2 * tab) + "%sout = [") + print((2 * tab) + "out = [") for browser in ["Firefox", "Safari", "Chrome", "Obscure"]: for it in filtered[browser]: diff --git a/tests/conftest.py b/tests/conftest.py index e6fff31ae..f08208406 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -184,13 +184,12 @@ def pytest_sessionfinish(exitstatus): if not getattr(sys, 'frozen', False): - def pytest_bdd_apply_tag(tag, function): + def _get_version_tag(tag): """Handle tags like pyqt>=5.3.1 for BDD tests. This transforms e.g. pyqt>=5.3.1 into an appropriate @pytest.mark.skip marker, and falls back to pytest-bdd's implementation for all other casesinto an appropriate @pytest.mark.skip marker, and falls back to - pytest-bdd's implementation for all other cases """ version_re = re.compile(r""" (?Pqt|pyqt) @@ -200,7 +199,6 @@ if not getattr(sys, 'frozen', False): match = version_re.match(tag) if not match: - # Use normal tag mapping return None operators = { @@ -217,15 +215,37 @@ if not getattr(sys, 'frozen', False): version = match.group('version') if package == 'qt': - mark = pytest.mark.skipif(qtutils.version_check(version, op), + return pytest.mark.skipif(qtutils.version_check(version, op), reason='Needs ' + tag) elif package == 'pyqt': major, minor, patch = [int(e) for e in version.split('.')] hex_version = (major << 16) | (minor << 8) | patch - mark = pytest.mark.skipif(not op(PYQT_VERSION, hex_version), + return pytest.mark.skipif(not op(PYQT_VERSION, hex_version), reason='Needs ' + tag) else: raise ValueError("Invalid package {!r}".format(package)) - mark(function) - return True + def _get_qtwebengine_tag(tag): + """Handle a @qtwebengine_* tag.""" + pytest_marks = { + 'qtwebengine_todo': pytest.mark.qtwebengine_todo, + 'qtwebengine_skip': pytest.mark.qtwebengine_skip, + } + if not any(tag.startswith(t + ':') for t in pytest_marks): + return None + name, desc = tag.split(':', maxsplit=1) + return pytest_marks[name](desc) + + def pytest_bdd_apply_tag(tag, function): + """Handle custom tags for BDD tests. + + This tries various functions, and if none knows how to handle this tag, + it returns None so it falls back to pytest-bdd's implementation. + """ + funcs = [_get_version_tag, _get_qtwebengine_tag] + for func in funcs: + mark = func(tag) + if mark is not None: + mark(function) + return True + return None diff --git a/tests/end2end/data/click_element.html b/tests/end2end/data/click_element.html index 9e5ce5bb5..55bf8b88c 100644 --- a/tests/end2end/data/click_element.html +++ b/tests/end2end/data/click_element.html @@ -3,9 +3,11 @@ quteprocess.click_element test - Test Element + Test Element "Don't", he shouted Duplicate Duplicate +
+
link diff --git a/tests/end2end/data/downloads/issue1725.html b/tests/end2end/data/downloads/issue1725.html index b413e69f2..45f24de4e 100644 --- a/tests/end2end/data/downloads/issue1725.html +++ b/tests/end2end/data/downloads/issue1725.html @@ -5,7 +5,7 @@

Using :prompt-open-download with a file that has a loooooong filename

- + Download me!

diff --git a/tests/end2end/data/editor.html b/tests/end2end/data/editor.html index 11f43463d..9f5f9c067 100644 --- a/tests/end2end/data/editor.html +++ b/tests/end2end/data/editor.html @@ -12,6 +12,6 @@ - + diff --git a/tests/end2end/data/hints/html/simple.html b/tests/end2end/data/hints/html/simple.html index 40e908a13..52cd69cbb 100644 --- a/tests/end2end/data/hints/html/simple.html +++ b/tests/end2end/data/hints/html/simple.html @@ -8,6 +8,6 @@ Simple link - Follow me! + Follow me! diff --git a/tests/end2end/data/hints/html/wrapped.html b/tests/end2end/data/hints/html/wrapped.html index dcc05c8c7..2ebde1a24 100644 --- a/tests/end2end/data/hints/html/wrapped.html +++ b/tests/end2end/data/hints/html/wrapped.html @@ -1,6 +1,9 @@ - + diff --git a/tests/end2end/data/hints/html/zoom_precision.html b/tests/end2end/data/hints/html/zoom_precision.html index 5b9b73f99..25a828399 100644 --- a/tests/end2end/data/hints/html/zoom_precision.html +++ b/tests/end2end/data/hints/html/zoom_precision.html @@ -1,6 +1,9 @@ - + diff --git a/tests/end2end/data/hints/iframe_scroll.html b/tests/end2end/data/hints/iframe_scroll.html index 4c3f56453..16fb0177c 100644 --- a/tests/end2end/data/hints/iframe_scroll.html +++ b/tests/end2end/data/hints/iframe_scroll.html @@ -6,6 +6,6 @@ Scrolling inside an iframe - + diff --git a/tests/end2end/data/hints/input.html b/tests/end2end/data/hints/input.html index 87cc3b347..9c93da15e 100644 --- a/tests/end2end/data/hints/input.html +++ b/tests/end2end/data/hints/input.html @@ -6,6 +6,6 @@ Simple input -
+
diff --git a/tests/end2end/data/hints/link_blank.html b/tests/end2end/data/hints/link_blank.html index f738e61dc..fc11c0167 100644 --- a/tests/end2end/data/hints/link_blank.html +++ b/tests/end2end/data/hints/link_blank.html @@ -5,6 +5,6 @@ A link to use hints on - Follow me! + Follow me! diff --git a/tests/end2end/data/hints/short_dict.html b/tests/end2end/data/hints/short_dict.html new file mode 100644 index 000000000..b24bdcae0 --- /dev/null +++ b/tests/end2end/data/hints/short_dict.html @@ -0,0 +1,24 @@ + + + + + + Many links + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + 13 + 14 + + diff --git a/tests/end2end/data/javascript/issue906.html b/tests/end2end/data/javascript/issue906.html index 14378971f..dbbf54ee9 100644 --- a/tests/end2end/data/javascript/issue906.html +++ b/tests/end2end/data/javascript/issue906.html @@ -2,8 +2,8 @@ - - + + - + diff --git a/tests/end2end/data/prompt/jsalert.html b/tests/end2end/data/prompt/jsalert.html index d4af29850..0d5076b4f 100644 --- a/tests/end2end/data/prompt/jsalert.html +++ b/tests/end2end/data/prompt/jsalert.html @@ -10,6 +10,6 @@ - + diff --git a/tests/end2end/data/prompt/jsconfirm.html b/tests/end2end/data/prompt/jsconfirm.html index 292b5e2c8..d89137ebd 100644 --- a/tests/end2end/data/prompt/jsconfirm.html +++ b/tests/end2end/data/prompt/jsconfirm.html @@ -10,6 +10,6 @@ - + diff --git a/tests/end2end/data/prompt/jsprompt.html b/tests/end2end/data/prompt/jsprompt.html index bc4178a6f..d8c848553 100644 --- a/tests/end2end/data/prompt/jsprompt.html +++ b/tests/end2end/data/prompt/jsprompt.html @@ -10,6 +10,6 @@ - + diff --git a/tests/end2end/data/prompt/notifications.html b/tests/end2end/data/prompt/notifications.html index a4e08e50d..f96456ff2 100644 --- a/tests/end2end/data/prompt/notifications.html +++ b/tests/end2end/data/prompt/notifications.html @@ -34,6 +34,6 @@ - + diff --git a/tests/end2end/data/scroll/no_doctype.html b/tests/end2end/data/scroll/no_doctype.html new file mode 100644 index 000000000..7a2ca5cfe --- /dev/null +++ b/tests/end2end/data/scroll/no_doctype.html @@ -0,0 +1,214 @@ + + + + + Scrolling without doctype + + +
+0
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
+101
+102
+103
+104
+105
+106
+107
+108
+109
+110
+111
+112
+113
+114
+115
+116
+117
+118
+119
+120
+121
+122
+123
+124
+125
+126
+127
+128
+129
+130
+131
+132
+133
+134
+135
+136
+137
+138
+139
+140
+141
+142
+143
+144
+145
+146
+147
+148
+149
+150
+151
+152
+153
+154
+155
+156
+157
+158
+159
+160
+161
+162
+163
+164
+165
+166
+167
+168
+169
+170
+171
+172
+173
+174
+175
+176
+177
+178
+179
+180
+181
+182
+183
+184
+185
+186
+187
+188
+189
+190
+191
+192
+193
+194
+195
+196
+197
+198
+199
+This is a very long line so this page can be scrolled horizontally. Did you think this line would end here already? Nah, it does not. But now it will. Or will it? I think it's not long enough yet. But depending on your screen size, this is not even enough yet, so I'm typing some more gibberish here. Hopefully that helps. I'm glad if it did, I'm always happy to help. Really, you're welcome. Okay, okay, can I stop now?
+        
+ next link to test the --top-navigate argument for :scroll-page. + prev link to test the --bottom-navigate argument for :scroll-page. + + diff --git a/tests/end2end/data/scroll.html b/tests/end2end/data/scroll/simple.html similarity index 100% rename from tests/end2end/data/scroll.html rename to tests/end2end/data/scroll/simple.html diff --git a/tests/end2end/features/backforward.feature b/tests/end2end/features/backforward.feature index 4b7cf247b..1b43cac4d 100644 --- a/tests/end2end/features/backforward.feature +++ b/tests/end2end/features/backforward.feature @@ -11,12 +11,7 @@ Feature: Going back and forward. And I run :forward And I wait until data/backforward/2.txt is loaded And I reload - Then the requests should be: - data/backforward/1.txt - data/backforward/2.txt - data/backforward/1.txt - data/backforward/2.txt - And the session should look like: + Then the session should look like: windows: - tabs: - history: @@ -24,6 +19,7 @@ Feature: Going back and forward. - active: true url: http://localhost:*/data/backforward/2.txt + @qtwebengine_todo: FIXME why is this broken? Scenario: Going back in a new tab Given I open data/backforward/1.txt When I open data/backforward/2.txt @@ -92,6 +88,7 @@ Feature: Going back and forward. - url: http://localhost:*/data/backforward/2.txt - url: http://localhost:*/data/backforward/3.txt + @qtwebengine_skip: Causes 'Ignoring invalid URL being added to history' sometimes? Scenario: Going back too much with count. Given I open data/backforward/1.txt When I open data/backforward/2.txt @@ -107,8 +104,9 @@ Feature: Going back and forward. Then the error "At beginning of history." should be shown And the message "Still alive!" should be shown + @qtwebengine_skip: flaky for some reason? Scenario: Going back in a new window - Given I have a fresh instance + Given I clean up open tabs When I open data/backforward/1.txt And I open data/backforward/2.txt And I run :back -w @@ -140,6 +138,7 @@ Feature: Going back and forward. When I run :forward Then the error "At end of history." should be shown + @qtwebengine_skip: Causes 'Ignoring invalid URL being added to history' sometimes? Scenario: Going forward too much with count. Given I open data/backforward/1.txt When I open data/backforward/2.txt diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index d8a0b261b..be72ad653 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -35,6 +35,27 @@ from qutebrowser.utils import log from helpers import utils +def pytest_collection_modifyitems(config, items): + """Apply @qtwebengine_* markers.""" + webengine = config.getoption('--qute-bdd-webengine') + + markers = { + 'qtwebengine_todo': ('QtWebEngine TODO', pytest.mark.xfail), + 'qtwebengine_skip': ('Skipped with QtWebEngine', pytest.mark.skipif), + } + + for item in items: + for name, (prefix, pytest_mark) in markers.items(): + marker = item.get_marker(name) + if marker: + if marker.args: + text = '{}: {}'.format(prefix, marker.args[0]) + else: + text = prefix + item.add_marker(pytest_mark(webengine, reason=text, + **marker.kwargs)) + + @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): """Add a BDD section to the test output.""" @@ -139,6 +160,15 @@ def fresh_instance(quteproc): quteproc.start() +@bdd.given("I clean up open tabs") +def clean_open_tabs(quteproc): + """Clean up open windows and tabs.""" + quteproc.set_setting('tabs', 'last-close', 'blank') + quteproc.send_cmd(':window-only') + quteproc.send_cmd(':tab-only') + quteproc.send_cmd(':tab-close') + + ## When @@ -147,15 +177,18 @@ def open_path(quteproc, path): """Open a URL. If used like "When I open ... in a new tab", the URL is opened in a new - tab. With "... in a new window", it's opened in a new window. + tab. With "... in a new window", it's opened in a new window. With + "... as a URL", it's opened according to new-instance-open-target. """ new_tab = False new_window = False + as_url = False wait = True new_tab_suffix = ' in a new tab' new_window_suffix = ' in a new window' do_not_wait_suffix = ' without waiting' + as_url_suffix = ' as a URL' if path.endswith(new_tab_suffix): path = path[:-len(new_tab_suffix)] @@ -163,12 +196,16 @@ def open_path(quteproc, path): elif path.endswith(new_window_suffix): path = path[:-len(new_window_suffix)] new_window = True + elif path.endswith(as_url_suffix): + path = path[:-len(as_url_suffix)] + as_url = True if path.endswith(do_not_wait_suffix): path = path[:-len(do_not_wait_suffix)] wait = False - quteproc.open_path(path, new_tab=new_tab, new_window=new_window, wait=wait) + quteproc.open_path(path, new_tab=new_tab, new_window=new_window, + as_url=as_url, wait=wait) @bdd.when(bdd.parsers.parse("I set {sect} -> {opt} to {value}")) @@ -282,6 +319,20 @@ def fill_clipboard_multiline(quteproc, httpbin, what, content): fill_clipboard(quteproc, httpbin, what, textwrap.dedent(content)) +@bdd.when(bdd.parsers.parse('I hint with args "{args}"')) +def hint(quteproc, args): + quteproc.send_cmd(':hint {}'.format(args)) + quteproc.wait_for(message='hints: *') + + +@bdd.when(bdd.parsers.parse('I hint with args "{args}" and follow {letter}')) +def hint_and_follow(quteproc, args, letter): + args = args.replace('(testdata)', utils.abs_datapath()) + quteproc.send_cmd(':hint {}'.format(args)) + quteproc.wait_for(message='hints: *') + quteproc.send_cmd(':follow-hint {}'.format(letter)) + + ## Then @@ -373,12 +424,16 @@ def javascript_message_not_logged(quteproc, message): @bdd.then(bdd.parsers.parse("The session should look like:\n{expected}")) -def compare_session(quteproc, expected): +def compare_session(request, quteproc, expected): """Compare the current sessions against the given template. partial_compare is used, which means only the keys/values listed will be compared. """ + if request.config.getoption('--qute-bdd-webengine'): + # pylint: disable=no-member + pytest.xfail(reason="QtWebEngine TODO: Sessions are not implemented") + # pylint: enable=no-member quteproc.compare_session(expected) @@ -442,13 +497,17 @@ def check_contents_json(quteproc, text): @bdd.then(bdd.parsers.parse("the following tabs should be open:\n{tabs}")) -def check_open_tabs(quteproc, tabs): +def check_open_tabs(quteproc, request, tabs): """Check the list of open tabs in the session. This is a lightweight alternative for "The session should look like: ...". It expects a list of URLs, with an optional "(active)" suffix. """ + if request.config.getoption('--qute-bdd-webengine'): + # pylint: disable=no-member + pytest.xfail(reason="QtWebEngine TODO: Sessions are not implemented") + # pylint: enable=no-member session = quteproc.get_session() active_suffix = ' (active)' tabs = tabs.splitlines() @@ -497,13 +556,17 @@ def should_quit(qtbot, quteproc): def _get_scroll_values(quteproc): data = quteproc.get_session() - pos = data['windows'][0]['tabs'][0]['history'][0]['scroll-pos'] + pos = data['windows'][0]['tabs'][0]['history'][-1]['scroll-pos'] return (pos['x'], pos['y']) @bdd.then(bdd.parsers.re(r"the page should be scrolled " r"(?Phorizontally|vertically)")) -def check_scrolled(quteproc, direction): +def check_scrolled(request, quteproc, direction): + if request.config.getoption('--qute-bdd-webengine'): + # pylint: disable=no-member + pytest.xfail(reason="QtWebEngine TODO: Sessions are not implemented") + # pylint: enable=no-member x, y = _get_scroll_values(quteproc) if direction == 'horizontally': assert x != 0 @@ -514,7 +577,11 @@ def check_scrolled(quteproc, direction): @bdd.then("the page should not be scrolled") -def check_not_scrolled(quteproc): +def check_not_scrolled(request, quteproc): + if request.config.getoption('--qute-bdd-webengine'): + # pylint: disable=no-member + pytest.xfail(reason="QtWebEngine TODO: Sessions are not implemented") + # pylint: enable=no-member x, y = _get_scroll_values(quteproc) assert x == 0 assert y == 0 diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index 5b623442d..35ad7da12 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -7,8 +7,7 @@ Feature: Downloading things from a website. Scenario: Downloading which redirects with closed tab (issue 889) When I set tabs -> last-close to blank And I open data/downloads/issue889.html - And I run :hint links download - And I run :follow-hint a + And I hint with args "links download" and follow a And I run :tab-close And I wait for "* Handling redirect" in the log Then no crash should happen @@ -16,8 +15,7 @@ Feature: Downloading things from a website. Scenario: Downloading with error in closed tab (issue 889) When I set tabs -> last-close to blank And I open data/downloads/issue889.html - And I run :hint links download - And I run :follow-hint s + And I hint with args "links download" and follow s And I run :tab-close And I wait for the error "Download error: * - server replied: NOT FOUND" And I run :download-retry @@ -28,8 +26,7 @@ Feature: Downloading things from a website. When I set completion -> download-path-suggestion to filename And I set storage -> prompt-download-directory to true And I open data/downloads/issue1243.html - And I run :hint links download - And I run :follow-hint a + And I hint with args "links download" and follow a And I wait for "Asking question text='Save file to:'>, *" in the log Then the error "Download error: No handler found for qute://!" should be shown @@ -37,8 +34,7 @@ Feature: Downloading things from a website. When I set completion -> download-path-suggestion to filename And I set storage -> prompt-download-directory to true And I open data/downloads/issue1214.html - And I run :hint links download - And I run :follow-hint a + And I hint with args "links download" and follow a And I wait for "Asking question text='Save file to:'>, *" in the log And I run :leave-mode Then no crash should happen @@ -201,8 +197,7 @@ Feature: Downloading things from a website. Scenario: Directly open a download with a very long filename When I set storage -> prompt-download-directory to true And I open data/downloads/issue1725.html - And I run :hint - And I run :follow-hint a + And I run :click-element id long-link And I wait for "Asking question text='Save file to:'>, *" in the log And I directly open the download And I wait until the download is finished diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature index 695ae5f93..aac8413e6 100644 --- a/tests/end2end/features/editor.feature +++ b/tests/end2end/features/editor.feature @@ -61,11 +61,9 @@ Feature: Opening external editors Scenario: Spawning an editor successfully When I set up a fake editor returning "foobar" And I open data/editor.html - And I run :hint all - And I run :follow-hint a + And I run :click-element id qute-textarea And I wait for "Clicked editable element!" in the log And I run :open-editor And I wait for "Read back: foobar" in the log - And I run :hint all - And I run :follow-hint s + And I run :click-element id qute-button Then the javascript message "text: foobar" should be logged diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index 341bbaa96..6f05b7d6b 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -6,100 +6,91 @@ Feature: Using hints Scenario: Using :follow-hint with an invalid index. When I open data/hints/html/simple.html - And I run :hint links normal - And I run :follow-hint xyz + And I hint with args "links normal" and follow xyz Then the error "No hint xyz!" should be shown ### Opening in current or new tab + @qtwebengine_todo: createWindow is not implemented yet Scenario: Following a hint and force to open in current tab. When I open data/hints/link_blank.html - And I run :hint links current - And I run :follow-hint a + And I hint with args "links current" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: - data/hello.txt (active) + @qtwebengine_todo: createWindow is not implemented yet Scenario: Following a hint and allow to open in new tab. When I open data/hints/link_blank.html - And I run :hint links normal - And I run :follow-hint a + And I hint with args "links normal" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: - data/hints/link_blank.html - data/hello.txt (active) + @qtwebengine_todo: createWindow is not implemented yet Scenario: Following a hint to link with sub-element and force to open in current tab. When I open data/hints/link_span.html And I run :tab-close - And I run :hint links current - And I run :follow-hint a + And I hint with args "links current" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: - data/hello.txt (active) Scenario: Entering and leaving hinting mode (issue 1464) - When I open data/hints/html/simple.html - And I run :hint - And I run :fake-key -g - Then no crash should happen + When I open data/hints/html/simple.html + And I hint with args "all" + And I run :fake-key -g + Then no crash should happen Scenario: Using :hint spawn with flags and -- (issue 797) When I open data/hints/html/simple.html - And I run :hint -- all spawn -v echo - And I run :follow-hint a + And I hint with args "-- all spawn -v echo" and follow a Then the message "Command exited successfully." should be shown Scenario: Using :hint spawn with flags (issue 797) When I open data/hints/html/simple.html - And I run :hint all spawn -v echo - And I run :follow-hint a + And I hint with args "all spawn -v echo" and follow a Then the message "Command exited successfully." should be shown Scenario: Using :hint spawn with flags and --rapid (issue 797) When I open data/hints/html/simple.html - And I run :hint --rapid all spawn -v echo - And I run :follow-hint a + And I hint with args "--rapid all spawn -v echo" and follow a Then the message "Command exited successfully." should be shown @posix Scenario: Using :hint spawn with flags passed to the command (issue 797) When I open data/hints/html/simple.html - And I run :hint --rapid all spawn -v echo -e foo - And I run :follow-hint a + And I hint with args "--rapid all spawn -v echo -e foo" and follow a Then the message "Command exited successfully." should be shown Scenario: Using :hint run When I open data/hints/html/simple.html - And I run :hint all run message-info {hint-url} - And I run :follow-hint a + And I hint with args "all run message-info {hint-url}" and follow a Then the message "http://localhost:(port)/data/hello.txt" should be shown Scenario: Using :hint fill When I open data/hints/html/simple.html - And I run :hint all fill :message-info {hint-url} - And I run :follow-hint a + And I hint with args "all fill :message-info {hint-url}" and follow a And I press the key "" Then the message "http://localhost:(port)/data/hello.txt" should be shown @posix Scenario: Using :hint userscript When I open data/hints/html/simple.html - And I run :hint all userscript (testdata)/userscripts/echo_hint_text - And I run :follow-hint a + And I hint with args "all userscript (testdata)/userscripts/echo_hint_text" and follow a Then the message "Follow me!" should be shown Scenario: Yanking to primary selection without it being supported (#1336) When selection is not supported And I run :debug-set-fake-clipboard And I open data/hints/html/simple.html - And I run :hint links yank-primary - And I run :follow-hint a + And I hint with args "links yank-primary" and follow a Then the clipboard should contain "http://localhost:(port)/data/hello.txt" Scenario: Using hint --rapid to hit multiple buttons When I open data/hints/buttons.html - And I run :hint --rapid + And I hint with args "--rapid" And I run :follow-hint s And I run :follow-hint d And I run :follow-hint f @@ -109,20 +100,17 @@ Feature: Using hints Scenario: Using :hint run with a URL containing spaces When I open data/hints/html/with_spaces.html - And I run :hint all run message-info {hint-url} - And I run :follow-hint a + And I hint with args "all run message-info {hint-url}" and follow a Then the message "http://localhost:(port)/data/hello.txt" should be shown Scenario: Clicking an invalid link When I open data/invalid_link.html - And I run :hint all - And I run :follow-hint a + And I hint with args "all" and follow a Then the error "Invalid link clicked - *" should be shown Scenario: Hinting inputs without type When I open data/hints/input.html - And I run :hint inputs - And I run :follow-hint a + And I hint with args "inputs" and follow a And I wait for "Entering mode KeyMode.insert (reason: click)" in the log And I run :leave-mode # The actual check is already done above @@ -130,33 +118,31 @@ Feature: Using hints ### iframes + @qtwebengine_todo: Hinting in iframes is not implemented yet Scenario: Using :follow-hint inside an iframe When I open data/hints/iframe.html - And I run :hint links normal - And I run :follow-hint a - Then "acceptNavigationRequest, url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged + And I hint with args "links normal" and follow a + Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged ### FIXME currenly skipped, see https://github.com/The-Compiler/qutebrowser/issues/1525 @xfail_norun Scenario: Using :follow-hint inside a scrolled iframe When I open data/hints/iframe_scroll.html - And I run :hint all normal - And I run :follow-hint a + And I hint with args "all normal" and follow a And I run :scroll bottom - And I run :hint links normal - And I run :follow-hint a - Then "acceptNavigationRequest, url http://localhost:*/data/hello2.txt, type NavigationTypeLinkClicked, *" should be logged + And I hint wht args "links normal" and follow a + Then "navigation request: url http://localhost:*/data/hello2.txt, type NavigationTypeLinkClicked, *" should be logged + @qtwebengine_todo: createWindow is not implemented yet Scenario: Opening a link inside a specific iframe When I open data/hints/iframe_target.html - And I run :hint links normal - And I run :follow-hint a - Then "acceptNavigationRequest, url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged + And I hint with args "links normal" and follow a + Then "navigation request: url http://localhost:*/data/hello.txt, type NavigationTypeLinkClicked, *" should be logged + @qtwebengine_todo: createWindow is not implemented yet Scenario: Opening a link with specific target frame in a new tab When I open data/hints/iframe_target.html - And I run :hint links tab - And I run :follow-hint a + And I hint with args "links tab" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: - data/hints/iframe_target.html @@ -169,7 +155,7 @@ Feature: Using hints And I set hints -> mode to number And I run :bind --force , message-error "This should not happen" And I open data/hints/html/simple.html - And I run :hint all + And I hint with args "all" And I press the key "f" And I wait until data/hello.txt is loaded And I press the key "," @@ -182,28 +168,39 @@ Feature: Using hints And I set hints -> mode to number And I run :bind --force , message-info "Keypress worked!" And I open data/hints/html/simple.html - And I run :hint all + And I hint with args "all" And I press the key "f" And I wait until data/hello.txt is loaded And I press the key "," Then the message "Keypress worked!" should be shown + ### Word hints + + Scenario: Hinting with a too short dictionary + When I open data/hints/short_dict.html + And I set hints -> mode to word + # Test letter fallback + And I hint with args "all" and follow d + Then the error "Not enough words in the dictionary." should be shown + And data/numbers/5.txt should be loaded + ### Number hint mode # https://github.com/The-Compiler/qutebrowser/issues/308 Scenario: Renumbering hints when filtering When I open data/hints/number.html And I set hints -> mode to number - And I run :hint all + And I hint with args "all" And I press the key "s" And I run :follow-hint 1 Then data/numbers/7.txt should be loaded # https://github.com/The-Compiler/qutebrowser/issues/576 + @qtwebengine_skip: Flaky for some reason Scenario: Keeping hint filter in rapid mode When I open data/hints/number.html And I set hints -> mode to number - And I run :hint all tab-bg --rapid + And I hint with args "all tab-bg --rapid" And I press the key "t" And I run :follow-hint 0 And I run :follow-hint 1 @@ -214,7 +211,7 @@ Feature: Using hints Scenario: Keeping hints filter when using backspace When I open data/hints/issue1186.html And I set hints -> mode to number - And I run :hint all + And I hint with args "all" And I press the key "x" And I press the key "0" And I press the key "" @@ -225,9 +222,9 @@ Feature: Using hints Scenario: Multi-word matching When I open data/hints/number.html And I set hints -> mode to number - And I set hints -> auto-follow to true + And I set hints -> auto-follow to unique-match And I set hints -> auto-follow-timeout to 0 - And I run :hint all + And I hint with args "all" And I press the keys "ten pos" Then data/numbers/11.txt should be loaded @@ -235,15 +232,14 @@ Feature: Using hints When I open data/hints/number.html And I set hints -> mode to number And I set hints -> scatter to true - And I run :hint all - And I run :follow-hint 00 + And I hint with args "all" and follow 00 Then data/numbers/1.txt should be loaded # https://github.com/The-Compiler/qutebrowser/issues/1559 Scenario: Filtering all hints in number mode When I open data/hints/number.html And I set hints -> mode to number - And I run :hint all + And I hint with args "all" And I press the key "2" And I wait for "Leaving mode KeyMode.hint (reason: all filtered)" in the log Then no crash should happen @@ -252,8 +248,145 @@ Feature: Using hints Scenario: Using rapid number hinting twice When I open data/hints/number.html And I set hints -> mode to number - And I run :hint --rapid + And I hint with args "--rapid" And I run :leave-mode - And I run :hint --rapid - And I run :follow-hint 00 + And I hint with args "--rapid" and follow 00 Then data/numbers/1.txt should be loaded + + Scenario: Using a specific hints mode + When I open data/hints/number.html + And I set hints -> mode to letter + And I hint with args "--mode number all" + And I press the key "s" + And I run :follow-hint 1 + Then data/numbers/7.txt should be loaded + + ### auto-follow option + + Scenario: Using hints -> auto-follow == 'always' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to always + And I hint with args "all" + Then data/hello.txt should be loaded + + # unique-match is actually the same as full-match in letter mode + Scenario: Using hints -> auto-follow == 'unique-match' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to unique-match + And I hint with args "all" + And I press the key "a" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'full-match' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to full-match + And I hint with args "all" + And I press the key "a" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'never' without Enter in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to never + And I hint with args "all" + And I press the key "a" + Then "Leaving mode KeyMode.hint (reason: followed)" should not be logged + + Scenario: Using hints -> auto-follow == 'never' in letter mode + When I open data/hints/html/simple.html + And I set hints -> mode to letter + And I set hints -> auto-follow to never + And I hint with args "all" + And I press the key "a" + And I press the key "" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'always' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to always + And I hint with args "all" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'unique-match' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to unique-match + And I hint with args "all" + And I press the key "f" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'full-match' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to full-match + And I hint with args "all" + # this actually presses the keys one by one + And I press the key "follow me!" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'never' without Enter in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to never + And I hint with args "all" + # this actually presses the keys one by one + And I press the key "follow me!" + Then "Leaving mode KeyMode.hint (reason: followed)" should not be logged + + Scenario: Using hints -> auto-follow == 'never' in number mode + When I open data/hints/html/simple.html + And I set hints -> mode to number + And I set hints -> auto-follow to never + And I hint with args "all" + # this actually presses the keys one by one + And I press the key "follow me!" + And I press the key "" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'always' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to always + And I hint with args "all" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'unique-match' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to unique-match + And I hint with args "all" + # the link gets "hello" as the hint + And I press the key "h" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'full-match' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to full-match + And I hint with args "all" + # this actually presses the keys one by one + And I press the key "hello" + Then data/hello.txt should be loaded + + Scenario: Using hints -> auto-follow == 'never' without Enter in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to never + And I hint with args "all" + # this actually presses the keys one by one + And I press the key "hello" + Then "Leaving mode KeyMode.hint (reason: followed)" should not be logged + + Scenario: Using hints -> auto-follow == 'never' in word mode + When I open data/hints/html/simple.html + And I set hints -> mode to word + And I set hints -> auto-follow to never + And I hint with args "all" + # this actually presses the keys one by one + And I press the key "hello" + And I press the key "" + Then data/hello.txt should be loaded diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature index 1cd680d7e..badb26aac 100644 --- a/tests/end2end/features/history.feature +++ b/tests/end2end/features/history.feature @@ -34,13 +34,14 @@ Feature: Page history Then the history file should contain: http://localhost:(port)/data/%C3%A4%C3%B6%C3%BC.html Chäschüechli - @flaky_once + @flaky_once @qtwebengine_todo: Error page message is not implemented Scenario: History with an error When I run :open file:///does/not/exist And I wait for "Error while loading file:///does/not/exist: Error opening /does/not/exist: *" in the log Then the history file should contain: file:///does/not/exist Error loading page: file:///does/not/exist + @qtwebengine_todo: Error page message is not implemented Scenario: History with a 404 When I open status/404 without waiting And I wait for "Error while loading http://localhost:*/status/404: NOT FOUND" in the log @@ -54,6 +55,7 @@ Feature: Page history ## Bugs + @qtwebengine_skip Scenario: Opening a valid URL which turns out invalid When I set general -> auto-search to true And I run :open http://foo%40bar@baz diff --git a/tests/end2end/features/invoke.feature b/tests/end2end/features/invoke.feature new file mode 100644 index 000000000..915f4a3af --- /dev/null +++ b/tests/end2end/features/invoke.feature @@ -0,0 +1,108 @@ +Feature: Invoking a new process + Simulate what happens when running qutebrowser with an existing instance + + Background: + Given I clean up open tabs + + Scenario: Using new-instance-open-target = tab + When I set general -> new-instance-open-target to tab + And I open data/title.html + And I open data/search.html as a URL + Then the following tabs should be open: + - data/title.html + - data/search.html (active) + + Scenario: Using new-instance-open-target = tab-bg + When I set general -> new-instance-open-target to tab-bg + And I open data/title.html + And I open data/search.html as a URL + Then the following tabs should be open: + - data/title.html (active) + - data/search.html + + Scenario: Using new-instance-open-target = window + When I set general -> new-instance-open-target to window + And I open data/title.html + And I open data/search.html as a URL + Then the session should look like: + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + - tabs: + - history: + - url: http://localhost:*/data/search.html + + Scenario: Using new-instance-open-target.window = last-opened + When I set general -> new-instance-open-target to tab + And I set general -> new-instance-open-target.window to last-opened + And I open data/title.html + And I open data/search.html in a new window + And I open data/hello.txt as a URL + Then the session should look like: + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + - tabs: + - history: + - url: http://localhost:*/data/search.html + - history: + - url: http://localhost:*/data/hello.txt + + Scenario: Using new-instance-open-target.window = first-opened + When I set general -> new-instance-open-target to tab + And I set general -> new-instance-open-target.window to first-opened + And I open data/title.html + And I open data/search.html in a new window + And I open data/hello.txt as a URL + Then the session should look like: + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + - history: + - url: http://localhost:*/data/hello.txt + - tabs: + - history: + - url: http://localhost:*/data/search.html + + # issue #1060 + + Scenario: Using target.window = first-opened after tab-detach + When I set general -> new-instance-open-target to tab + And I set general -> new-instance-open-target.window to first-opened + And I open data/title.html + And I open data/search.html in a new tab + And I run :tab-detach + And I open data/hello.txt as a URL + Then the session should look like: + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + - history: + - url: http://localhost:*/data/hello.txt + - tabs: + - history: + - url: http://localhost:*/data/search.html + + Scenario: Opening a new qutebrowser instance with no parameters + When I set general -> new-instance-open-target to tab + And I set general -> startpage to about:blank + And I open data/title.html + And I spawn a new window + And I wait until about:blank is loaded + Then the session should look like: + windows: + - tabs: + - history: + - url: about:blank + - url: http://localhost:*/data/title.html + - tabs: + - history: + - url: about:blank diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index 979dd5cbf..b95e4e7d7 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -9,13 +9,12 @@ Feature: Javascript stuff # https://github.com/The-Compiler/qutebrowser/issues/906 + @qtwebengine_todo: createWindow is not implemented yet Scenario: Closing a JS window twice (issue 906) When I open about:blank And I open data/javascript/issue906.html in a new tab - And I run :hint - And I run :follow-hint a + And I run :click-element id open-button And I wait for "Changing title for idx 2 to 'about:blank'" in the log And I run :tab-focus 2 - And I run :hint - And I run :follow-hint s + And I run :click-element id close-button Then "Requested to close * which does not exist!" should be logged diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index 6287c645f..3ae32368b 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -61,6 +61,18 @@ Feature: Keyboard input And I run :bind Then the message " is bound to 'message-info bar' in normal mode" should be shown + Scenario: Binding to an alias + When I run :set aliases 'mib' 'message-info baz' + And I run :bind test25 mib + And I press the keys "test25" + Then the message "baz" should be shown + + Scenario: Printing a bound alias + When I run :set aliases 'mib' 'message-info baz' + And I run :bind mib + And I run :bind + Then the message " is bound to 'mib' in normal mode" should be shown + # :unbind Scenario: Binding and unbinding a keychain diff --git a/tests/end2end/features/marks.feature b/tests/end2end/features/marks.feature index 6491a67e1..af09187bb 100644 --- a/tests/end2end/features/marks.feature +++ b/tests/end2end/features/marks.feature @@ -55,6 +55,7 @@ Feature: Setting positional marks And I run :jump-mark b Then the error "Mark b is not set" should be shown + @qtwebengine_todo: Does not emit loaded signal for fragments? Scenario: Jumping to a local mark after changing fragments When I open data/marks.html#top And I run :scroll 'top' @@ -64,9 +65,9 @@ Feature: Setting positional marks And I run :jump-mark a Then the page should be scrolled to 10 10 + @qtwebengine_todo: Does not emit loaded signal for fragments? Scenario: Jumping back after following a link - When I run :hint links normal - And I run :follow-hint s + When I hint with args "links normal" and follow s And I wait until data/marks.html#bottom is loaded And I run :jump-mark "'" Then the page should be scrolled to 0 0 @@ -86,7 +87,6 @@ Feature: Setting positional marks Scenario: Hovering a hint does not set the ' mark When I run :scroll-px 30 20 And I run :scroll-perc 0 - And I run :hint links hover - And I run :follow-hint s + And I hint with args "links hover" and follow s And I run :jump-mark "'" Then the page should be scrolled to 30 20 diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 686f5a8b4..39f44e31e 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -64,19 +64,19 @@ Feature: Various utility commands. Scenario: :jseval When I set general -> log-javascript-console to info And I run :jseval console.log("Hello from JS!"); - And I wait for "[:0] Hello from JS!" in the log + And I wait for "[:*] Hello from JS!" in the log Then the message "No output or error" should be shown Scenario: :jseval without logging When I set general -> log-javascript-console to none And I run :jseval console.log("Hello from JS!"); Then the message "No output or error" should be shown - And "[:0] Hello from JS!" should not be logged + And "[:*] Hello from JS!" should not be logged Scenario: :jseval with --quiet When I set general -> log-javascript-console to info And I run :jseval --quiet console.log("Hello from JS!"); - And I wait for "[:0] Hello from JS!" in the log + And I wait for "[:*] Hello from JS!" in the log Then "No output or error" should not be logged Scenario: :jseval with a value @@ -190,6 +190,7 @@ Feature: Various utility commands. # :view-source + @qtwebengine_skip: Flaky due to :view-source being async Scenario: :view-source Given I open data/hello.txt When I run :tab-only @@ -204,6 +205,7 @@ Feature: Various utility commands. history: [] And the page source should look like misc/hello.txt.html + @qtwebengine_skip: Flaky due to :view-source being async Scenario: :view-source on source page. When I open data/hello.txt And I run :view-source @@ -222,6 +224,7 @@ Feature: Various utility commands. # :help + @qtwebengine_todo: :help is not implemented yet Scenario: :help without topic When I run :tab-only And I run :help @@ -233,6 +236,7 @@ Feature: Various utility commands. When I run :help foo Then the error "Invalid help topic foo!" should be shown + @qtwebengine_todo: :help is not implemented yet Scenario: :help with command When the documentation is up to date And I run :tab-only @@ -245,6 +249,7 @@ Feature: Various utility commands. When I run :help :foo Then the error "Invalid command foo!" should be shown + @qtwebengine_todo: :help is not implemented yet Scenario: :help with setting When the documentation is up to date And I run :tab-only @@ -265,6 +270,7 @@ Feature: Various utility commands. When I run :help general->bar Then the error "Invalid option bar!" should be shown + @qtwebengine_todo: :help is not implemented yet Scenario: :help with -t When I open about:blank And I run :tab-only @@ -288,18 +294,21 @@ Feature: Various utility commands. # pdfjs support + @qtwebengine_todo: pdfjs is not implemented yet Scenario: pdfjs is used for pdf files Given pdfjs is available When I set content -> enable-pdfjs to true And I open data/misc/test.pdf Then the javascript message "PDF * [*] (PDF.js: *)" should be logged + @qtwebengine_todo: pdfjs is not implemented yet Scenario: pdfjs is not used when disabled When I set content -> enable-pdfjs to false And I set storage -> prompt-download-directory to false And I open data/misc/test.pdf Then "Download finished" should be logged + @qtwebengine_todo: pdfjs is not implemented yet Scenario: Downloading a pdf via pdf.js button (issue 1214) Given pdfjs is available # WORKAROUND to prevent the "Painter ended with 2 saved states" warning @@ -339,6 +348,8 @@ Feature: Various utility commands. And I run :debug-pyeval QApplication.instance().activeModalWidget().close() Then no crash should happen + # FIXME:qtwebengine use a finer skipping here + @qtwebengine_skip: printing to pdf is not implemented with older Qt versions Scenario: print --pdf When I open data/hello.txt And I run :print --pdf (tmpdir)/hello.pdf @@ -346,12 +357,13 @@ Feature: Various utility commands. Then the PDF hello.pdf should exist in the tmpdir # :pyeval - + @qtwebengine_todo: qute:pyeval is not implemented yet Scenario: Running :pyeval When I run :debug-pyeval 1+1 And I wait until qute:pyeval is loaded Then the page should contain the plaintext "2" + @qtwebengine_todo: qute:pyeval is not implemented yet Scenario: Causing exception in :pyeval When I run :debug-pyeval 1/0 And I wait until qute:pyeval is loaded @@ -369,11 +381,10 @@ Feature: Various utility commands. And I press the key "" Then no crash should happen - @pyqt>=5.3.1 + @pyqt>=5.3.1 @qtwebengine_todo: JS prompt is not implemented yet Scenario: Focusing download widget via Tab (original issue) When I open data/prompt/jsprompt.html - And I run :hint - And I run :follow-hint a + And I run :click-element id button And I wait for "Entering mode KeyMode.prompt *" in the log And I press the key "" And I press the key "" @@ -381,6 +392,7 @@ Feature: Various utility commands. ## Custom headers + @qtwebengine_todo: Custom headers are not implemented yet Scenario: Setting a custom header When I set network -> custom-headers to {"X-Qute-Test": "testvalue"} And I open headers @@ -388,6 +400,7 @@ Feature: Various utility commands. ## :messages + @qtwebengine_todo: qute:log is not implemented yet Scenario: Showing error messages When I run :message-error the-error-message And I run :message-warning the-warning-message @@ -400,6 +413,7 @@ Feature: Various utility commands. And the page should not contain the plaintext "the-warning-message" And the page should not contain the plaintext "the-info-message" + @qtwebengine_todo: qute:log is not implemented yet Scenario: Showing messages of type 'warning' or greater When I run :message-error the-error-message And I run :message-warning the-warning-message @@ -412,6 +426,7 @@ Feature: Various utility commands. And the page should contain the plaintext "the-warning-message" And the page should not contain the plaintext "the-info-message" + @qtwebengine_todo: qute:log is not implemented yet Scenario: Showing messages of type 'info' or greater When I run :message-error the-error-message And I run :message-warning the-warning-message @@ -424,24 +439,29 @@ Feature: Various utility commands. And the page should contain the plaintext "the-warning-message" And the page should contain the plaintext "the-info-message" + @qtwebengine_skip: Flaky for some reason? Scenario: Showing messages of an invalid level When I run :messages cataclysmic Then the error "Invalid log level cataclysmic!" should be shown + @qtwebengine_todo: qute:log is not implemented yet Scenario: Using qute:log directly When I open qute:log Then no crash should happen + @qtwebengine_todo: qute:log is not implemented yet Scenario: Using qute:plainlog directly When I open qute:plainlog Then no crash should happen + @qtwebengine_todo: qute:log is not implemented yet Scenario: Using :messages without messages Given I have a fresh instance When I run :messages Then qute://log?level=error should be loaded And the page should contain the plaintext "No messages to show." + @qtwebengine_todo: qute:log is not implemented yet Scenario: Using :debug-log-capacity When I run :debug-log-capacity 100 And I run :message-info oldstuff @@ -451,10 +471,26 @@ Feature: Various utility commands. Then the page should contain the plaintext "newstuff" And the page should not contain the plaintext "oldstuff" + Scenario: Using :debug-log-capacity with negative capacity + When I run :debug-log-capacity -1 + Then the error "Can't set a negative log capacity!" should be shown + + # :debug-log-level / :debug-log-filter + # Other :debug-log-{level,filter} features are tested in + # unit/utils/test_log.py as using them would break end2end tests. + + Scenario: Using debug-log-level with invalid level + When I run :debug-log-level hello + Then the error "level: Invalid value hello - expected one of: vdebug, debug, info, warning, error, critical" should be shown + + Scenario: Using debug-log-filter with invalid filter + When I run :debug-log-filter blah + Then the error "filters: Invalid value blah - expected one of: statusbar, *" should be shown + ## https://github.com/The-Compiler/qutebrowser/issues/1523 Scenario: Completing a single option argument - When I run :set-cmd-text -s :-- + When I run :set-cmd-text -s :-- Then no crash should happen ## https://github.com/The-Compiler/qutebrowser/issues/1386 @@ -473,6 +509,7 @@ Feature: Various utility commands. ## https://github.com/The-Compiler/qutebrowser/issues/1219 + @qtwebengine_todo: private browsing is not implemented yet Scenario: Sharing cookies with private browsing When I set general -> private-browsing to true And I open cookies/set?qute-test=42 without waiting @@ -482,13 +519,14 @@ Feature: Various utility commands. ## https://github.com/The-Compiler/qutebrowser/issues/1742 + @qtwebengine_todo: private browsing is not implemented yet Scenario: Private browsing is activated in QtWebKit without restart When I set general -> private-browsing to true And I open data/javascript/localstorage.html Then the page should contain the plaintext "Local storage status: not working" Scenario: :repeat-command - Given I open data/scroll.html + Given I open data/scroll/simple.html And I run :tab-only When I run :scroll down And I run :repeat-command @@ -496,7 +534,7 @@ Feature: Various utility commands. Then the page should be scrolled vertically Scenario: :repeat-command with count - Given I open data/scroll.html + Given I open data/scroll/simple.html And I run :tab-only When I run :scroll down with count 3 And I run :scroll up @@ -504,7 +542,7 @@ Feature: Various utility commands. Then the page should not be scrolled Scenario: :repeat-command with not-normal command inbetween - Given I open data/scroll.html + Given I open data/scroll/simple.html And I run :tab-only When I run :scroll down with count 3 And I run :scroll up @@ -513,10 +551,11 @@ Feature: Various utility commands. Then the page should not be scrolled And the error "prompt-accept: This command is only allowed in prompt/yesno mode." should be shown + @qtwebengine_todo: createWindow is not implemented yet Scenario: :repeat-command with mode-switching command Given I open data/hints/link_blank.html And I run :tab-only - When I run :hint + When I hint with args "all" And I run :leave-mode And I run :repeat-command And I run :follow-hint a @@ -524,3 +563,59 @@ Feature: Various utility commands. Then the following tabs should be open: - data/hints/link_blank.html - data/hello.txt (active) + + @no_xvfb + Scenario: :window-only + Given I run :tab-only + And I open data/hello.txt + When I open data/hello2.txt in a new tab + And I open data/hello3.txt in a new window + And I run :window-only + Then the session should look like: + windows: + - tabs: + - active: true + history: + - url: http://localhost:*/data/hello3.txt + + ## Variables + + Scenario: {url} as part of an argument + When I open data/hello.txt + And I run :message-info foo{url} + Then the message "foohttp://localhost:*/hello.txt" should be shown + + Scenario: Multiple variables in an argument + When I open data/hello.txt + And I put "foo" into the clipboard + And I run :message-info {clipboard}bar{url} + Then the message "foobarhttp://localhost:*/hello.txt" should be shown + + @xfail_norun + Scenario: {url} in clipboard should not be expanded + When I open data/hello.txt + # FIXME: {url} should be escaped, otherwise it is replaced before it enters clipboard + And I put "{url}" into the clipboard + And I run :message-info {clipboard}bar{url} + Then the message "{url}barhttp://localhost:*/hello.txt" should be shown + + ## :click-element + + Scenario: Clicking an element with unknown ID + When I open data/click_element.html + And I run :click-element id blah + Then the error "No element found!" should be shown + + Scenario: Clicking an element by ID + When I open data/click_element.html + And I run :click-element id qute-input + Then "Clicked editable element!" should be logged + + Scenario: Clicking an element with tab target + When I open data/click_element.html + And I run :tab-only + And I run :click-element id link --target=tab + Then data/hello.txt should be loaded + And the following tabs should be open: + - data/click_element.html + - data/hello.txt (active) diff --git a/tests/end2end/features/navigate.feature b/tests/end2end/features/navigate.feature index c43ff1c18..956229f09 100644 --- a/tests/end2end/features/navigate.feature +++ b/tests/end2end/features/navigate.feature @@ -11,6 +11,11 @@ Feature: Using :navigate And I run :navigate up Then data/navigate should be loaded + Scenario: Navigating up by count + When I open data/navigate/sub/index.html + And I run :navigate up with count 2 + Then data/navigate should be loaded + # prev/next Scenario: Navigating to previous page @@ -60,12 +65,23 @@ Feature: Using :navigate And I run :navigate increment Then the error "No number found in URL!" should be shown + Scenario: Incrementing number in URL by count + When I open data/numbers/3.txt + And I run :navigate increment with count 3 + Then data/numbers/6.txt should be loaded + + Scenario: Decrementing number in URL by count + When I open data/numbers/8.txt + And I run :navigate decrement with count 5 + Then data/numbers/3.txt should be loaded + Scenario: Setting url-incdec-segments When I set general -> url-incdec-segments to anchor And I open data/numbers/1.txt And I run :navigate increment Then the error "No number found in URL!" should be shown + @qtwebengine_todo: Doesn't find any elements Scenario: Navigating multiline links When I open data/navigate/multilinelinks.html And I run :navigate next diff --git a/tests/end2end/features/prompts.feature b/tests/end2end/features/prompts.feature index f781cf1d3..633f465b6 100644 --- a/tests/end2end/features/prompts.feature +++ b/tests/end2end/features/prompts.feature @@ -8,7 +8,7 @@ Feature: Prompts Scenario: Javascript alert When I open data/prompt/jsalert.html - And I click the button + And I run :click-element id button And I wait for a prompt And I run :prompt-accept Then the javascript message "Alert done" should be logged @@ -16,26 +16,26 @@ Feature: Prompts Scenario: Using content -> ignore-javascript-alert When I set content -> ignore-javascript-alert to true And I open data/prompt/jsalert.html - And I click the button + And I run :click-element id button Then the javascript message "Alert done" should be logged Scenario: Javascript confirm - yes When I open data/prompt/jsconfirm.html - And I click the button + And I run :click-element id button And I wait for a prompt And I run :prompt-yes Then the javascript message "confirm reply: true" should be logged Scenario: Javascript confirm - no When I open data/prompt/jsconfirm.html - And I click the button + And I run :click-element id button And I wait for a prompt And I run :prompt-no Then the javascript message "confirm reply: false" should be logged Scenario: Javascript confirm - aborted When I open data/prompt/jsconfirm.html - And I click the button + And I run :click-element id button And I wait for a prompt And I run :leave-mode Then the javascript message "confirm reply: false" should be logged @@ -43,7 +43,7 @@ Feature: Prompts @pyqt>=5.3.1 Scenario: Javascript prompt When I open data/prompt/jsprompt.html - And I click the button + And I run :click-element id button And I wait for a prompt And I press the keys "prompt test" And I run :prompt-accept @@ -52,7 +52,7 @@ Feature: Prompts @pyqt>=5.3.1 Scenario: Rejected javascript prompt When I open data/prompt/jsprompt.html - And I click the button + And I run :click-element id button And I wait for a prompt And I press the keys "prompt test" And I run :leave-mode @@ -66,7 +66,7 @@ Feature: Prompts When selection is supported And I put "insert test" into the primary selection And I open data/prompt/jsprompt.html - And I click the button + And I run :click-element id button And I wait for a prompt And I press the keys "" And I run :prompt-accept @@ -76,7 +76,7 @@ Feature: Prompts Scenario: Using content -> ignore-javascript-prompt When I set content -> ignore-javascript-prompt to true And I open data/prompt/jsprompt.html - And I click the button + And I run :click-element id button Then the javascript message "Prompt reply: null" should be logged # SSL @@ -119,21 +119,21 @@ Feature: Prompts Scenario: Always rejecting geolocation When I set content -> geolocation to false And I open data/prompt/geolocation.html in a new tab - And I click the button + And I run :click-element id button Then the javascript message "geolocation permission denied" should be logged @ci @not_osx Scenario: Always accepting geolocation When I set content -> geolocation to true And I open data/prompt/geolocation.html in a new tab - And I click the button + And I run :click-element id button Then the javascript message "geolocation permission denied" should not be logged @ci @not_osx Scenario: geolocation with ask -> true When I set content -> geolocation to ask And I open data/prompt/geolocation.html in a new tab - And I click the button + And I run :click-element id button And I wait for a prompt And I run :prompt-yes Then the javascript message "geolocation permission denied" should not be logged @@ -141,7 +141,7 @@ Feature: Prompts Scenario: geolocation with ask -> false When I set content -> geolocation to ask And I open data/prompt/geolocation.html in a new tab - And I click the button + And I run :click-element id button And I wait for a prompt And I run :prompt-no Then the javascript message "geolocation permission denied" should be logged @@ -149,7 +149,7 @@ Feature: Prompts Scenario: geolocation with ask -> abort When I set content -> geolocation to ask And I open data/prompt/geolocation.html in a new tab - And I click the button + And I run :click-element id button And I wait for a prompt And I run :leave-mode Then the javascript message "geolocation permission denied" should be logged @@ -159,19 +159,19 @@ Feature: Prompts Scenario: Always rejecting notifications When I set content -> notifications to false And I open data/prompt/notifications.html in a new tab - And I click the button + And I run :click-element id button Then the javascript message "notification permission denied" should be logged Scenario: Always accepting notifications When I set content -> notifications to true And I open data/prompt/notifications.html in a new tab - And I click the button + And I run :click-element id button Then the javascript message "notification permission granted" should be logged Scenario: notifications with ask -> false When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab - And I click the button + And I run :click-element id button And I wait for a prompt And I run :prompt-no Then the javascript message "notification permission denied" should be logged @@ -179,7 +179,7 @@ Feature: Prompts Scenario: notifications with ask -> true When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab - And I click the button + And I run :click-element id button And I wait for a prompt And I run :prompt-yes Then the javascript message "notification permission granted" should be logged @@ -189,7 +189,7 @@ Feature: Prompts Scenario: notifications with ask -> abort When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab - And I click the button + And I run :click-element id button And I wait for a prompt And I run :leave-mode Then the javascript message "notification permission aborted" should be logged @@ -197,7 +197,7 @@ Feature: Prompts Scenario: answering notification after closing tab When I set content -> notifications to ask And I open data/prompt/notifications.html in a new tab - And I click the button + And I run :click-element id button And I wait for a prompt And I run :tab-close And I wait for "Leaving mode KeyMode.yesno (reason: aborted)" in the log diff --git a/tests/end2end/features/scroll.feature b/tests/end2end/features/scroll.feature index 50c22971a..6b50a5522 100644 --- a/tests/end2end/features/scroll.feature +++ b/tests/end2end/features/scroll.feature @@ -2,7 +2,7 @@ Feature: Scrolling Tests the various scroll commands. Background: - Given I open data/scroll.html + Given I open data/scroll/simple.html And I run :tab-only ## :scroll-px @@ -72,6 +72,11 @@ Feature: Scrolling And I run :scroll left Then the page should not be scrolled + # causes segfault with postEvent instead of sendEvent + Scenario: Scrolling down with count 10 + When I run :scroll down with count 10 + Then no crash should happen + Scenario: Scrolling with page down When I run :scroll page-down Then the page should be scrolled vertically @@ -116,6 +121,7 @@ Feature: Scrolling And I run :scroll left Then the page should not be scrolled + @qtwebengine_skip: Causes memory leak... Scenario: Scrolling down with a very big count When I run :scroll down with count 99999999999 # Make sure it doesn't hang @@ -181,6 +187,7 @@ Feature: Scrolling When I run :scroll-perc with count 50 Then the page should be scrolled vertically + @qtwebengine_skip: Causes memory leak... Scenario: :scroll-perc with a very big value When I run :scroll-perc 99999999999 Then no crash should happen @@ -193,6 +200,12 @@ Feature: Scrolling Scenario: :scroll-perc with count and argument When I run :scroll-perc 0 with count 50 Then the page should be scrolled vertically + + # https://github.com/The-Compiler/qutebrowser/issues/1821 + Scenario: :scroll-perc without doctype + When I open data/scroll/no_doctype.html + And I run :scroll-perc 100 + Then the page should be scrolled vertically ## :scroll-page @@ -228,6 +241,7 @@ Feature: Scrolling And I run :scroll-page -1 0 Then the page should not be scrolled + @qtwebengine_todo: at_bottom is not implemented yet Scenario: :scroll-page with --bottom-navigate When I run :scroll-perc 100 And I run :scroll-page --bottom-navigate next 0 1 @@ -237,6 +251,7 @@ Feature: Scrolling When I run :scroll-page --top-navigate prev 0 -1 Then data/hello3.txt should be loaded + @qtwebengine_skip: Causes memory leak... Scenario: :scroll-page with a very big value When I run :scroll-page 99999999999 99999999999 Then the error "Numeric argument is too large for internal int representation." should be shown diff --git a/tests/end2end/features/set.feature b/tests/end2end/features/set.feature index aabb07f7e..6611b7ca0 100644 --- a/tests/end2end/features/set.feature +++ b/tests/end2end/features/set.feature @@ -56,6 +56,7 @@ Feature: Setting settings. When I run :set -t colors statusbar.bg green Then colors -> statusbar.bg should be green + @qtwebengine_todo: qute:settings is not implemented yet Scenario: Opening qute:settings When I run :set And I wait until qute:settings is loaded diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 42ad9152d..6e8242b14 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -263,8 +263,7 @@ Feature: Tab management Then the error "There's no tab with index -1!" should be shown Scenario: :tab-focus last with no last focused tab - Given I have a fresh instance - And I run :tab-focus last + When I run :tab-focus last Then the error "No last focused tab!" should be shown # tab-prev/tab-next @@ -564,7 +563,6 @@ Feature: Tab management - data/hello2.txt Scenario: Cloning to new window - Given I have a fresh instance When I open data/title.html And I run :tab-clone -w Then the session should look like: @@ -583,7 +581,6 @@ Feature: Tab management title: Test title Scenario: Cloning with tabs-are-windows = true - Given I have a fresh instance When I open data/title.html And I set tabs -> tabs-are-windows to true And I run :tab-clone @@ -605,7 +602,6 @@ Feature: Tab management # :tab-detach Scenario: Detaching a tab - Given I have a fresh instance When I open data/numbers/1.txt And I open data/numbers/2.txt in a new tab And I run :tab-detach @@ -620,6 +616,11 @@ Feature: Tab management - history: - url: http://localhost:*/data/numbers/2.txt + Scenario: Detach tab from window with only one tab + When I open data/hello.txt + And I run :tab-detach + Then the error "Cannot detach one tab." should be shown + # :undo Scenario: Undo without any closed tabs @@ -703,6 +704,53 @@ Feature: Tab management Then the error "Nothing to undo!" should be shown And the error "Nothing to undo!" should be shown + Scenario: Undo a tab closed by index + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I run :tab-close with count 1 + And I run :undo + Then the following tabs should be open: + - data/numbers/1.txt (active) + - data/numbers/2.txt + - data/numbers/3.txt + + Scenario: Undo a tab closed after switching tabs + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I run :tab-close with count 1 + And I run :tab-focus 2 + And I run :undo + Then the following tabs should be open: + - data/numbers/1.txt (active) + - data/numbers/2.txt + - data/numbers/3.txt + + Scenario: Undo a tab closed after rearranging tabs + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I open data/numbers/3.txt in a new tab + And I run :tab-close with count 1 + And I run :tab-focus 2 + And I run :tab-move with count 1 + And I run :undo + Then the following tabs should be open: + - data/numbers/1.txt (active) + - data/numbers/3.txt + - data/numbers/2.txt + + Scenario: Undo a tab closed after new tab opened + When I open data/numbers/1.txt + And I open data/numbers/2.txt in a new tab + And I run :tab-close with count 1 + And I open data/numbers/3.txt in a new tab + And I run :undo + Then the following tabs should be open: + - data/numbers/1.txt (active) + - data/numbers/2.txt + - data/numbers/3.txt + # last-close Scenario: last-close = blank @@ -746,8 +794,7 @@ Feature: Tab management Scenario: opening links with tabs->background-tabs true When I set tabs -> background-tabs to true And I open data/hints/html/simple.html - And I run :hint all tab - And I run :follow-hint a + And I hint with args "all tab" and follow a And I wait until data/hello.txt is loaded Then the following tabs should be open: - data/hints/html/simple.html (active) @@ -758,8 +805,7 @@ Feature: Tab management And I set tabs -> background-tabs to false And I open about:blank And I open data/hints/html/simple.html in a new tab - And I run :hint all tab - And I run :follow-hint a + And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: - about:blank @@ -771,8 +817,7 @@ Feature: Tab management And I set tabs -> background-tabs to false And I open about:blank And I open data/hints/html/simple.html in a new tab - And I run :hint all tab - And I run :follow-hint a + And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: - about:blank @@ -784,8 +829,7 @@ Feature: Tab management And I set tabs -> background-tabs to false And I open about:blank And I open data/hints/html/simple.html in a new tab - And I run :hint all tab - And I run :follow-hint a + And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: - data/hello.txt (active) @@ -798,8 +842,7 @@ Feature: Tab management And I open data/hints/html/simple.html And I open about:blank in a new tab And I run :tab-focus last - And I run :hint all tab - And I run :follow-hint a + And I run :click-element id link --target=tab And I wait until data/hello.txt is loaded Then the following tabs should be open: - data/hints/html/simple.html @@ -809,20 +852,19 @@ Feature: Tab management # :buffer Scenario: :buffer without args - Given I have a fresh instance When I run :buffer Then the error "buffer: The following arguments are required: index" should be shown Scenario: :buffer with a matching title When I open data/title.html And I open data/search.html in a new tab - And I open data/scroll.html in a new tab + And I open data/scroll/simple.html in a new tab And I run :buffer "Searching text" And I wait for "Current tab changed, focusing " in the log Then the following tabs should be open: - data/title.html - data/search.html (active) - - data/scroll.html + - data/scroll/simple.html Scenario: :buffer with no matching title When I run :buffer "invalid title" @@ -831,11 +873,11 @@ Feature: Tab management Scenario: :buffer with matching title and two windows When I open data/title.html And I open data/search.html in a new tab - And I open data/scroll.html in a new tab + And I open data/scroll/simple.html in a new tab And I open data/caret.html in a new window And I open data/paste_primary.html in a new tab And I run :buffer "Scrolling" - And I wait for "Focus object changed: " in the log + And I wait for "Focus object changed: *" in the log Then the session should look like: windows: - active: true @@ -847,7 +889,7 @@ Feature: Tab management - url: http://localhost:*/data/search.html - active: true history: - - url: http://localhost:*/data/scroll.html + - url: http://localhost:*/data/scroll/simple.html - tabs: - history: - url: http://localhost:*/data/caret.html @@ -862,19 +904,19 @@ Feature: Tab management Scenario: :buffer with no matching window index When I open data/title.html - And I run :buffer "2/1" - Then the error "There's no window with id 2!" should be shown + And I run :buffer "99/1" + Then the error "There's no window with id 99!" should be shown Scenario: :buffer with matching window index Given I have a fresh instance When I open data/title.html And I open data/search.html in a new tab - And I open data/scroll.html in a new tab + And I open data/scroll/simple.html in a new tab And I run :open -w http://localhost:(port)/data/caret.html And I open data/paste_primary.html in a new tab And I wait until data/caret.html is loaded And I run :buffer "0/2" - And I wait for "Focus object changed: " in the log + And I wait for "Focus object changed: *" in the log Then the session should look like: windows: - active: true @@ -886,7 +928,7 @@ Feature: Tab management history: - url: http://localhost:*/data/search.html - history: - - url: http://localhost:*/data/scroll.html + - url: http://localhost:*/data/scroll/simple.html - tabs: - history: - url: http://localhost:*/data/caret.html @@ -895,7 +937,6 @@ Feature: Tab management - url: http://localhost:*/data/paste_primary.html Scenario: :buffer with wrong argument (-1) - Given I have a fresh instance When I open data/title.html And I run :buffer "-1" Then the error "There's no tab with index -1!" should be shown diff --git a/tests/end2end/features/test_caret_bdd.py b/tests/end2end/features/test_caret_bdd.py index 611e00610..aa42241c4 100644 --- a/tests/end2end/features/test_caret_bdd.py +++ b/tests/end2end/features/test_caret_bdd.py @@ -17,10 +17,15 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import pytest import pytest_bdd as bdd # pylint: disable=unused-import from end2end.features.test_yankpaste_bdd import init_fake_clipboard +pytestmark = pytest.mark.qtwebengine_todo("Caret mode is not implemented", + run=False) + + bdd.scenarios('caret.feature') diff --git a/tests/end2end/features/test_downloads_bdd.py b/tests/end2end/features/test_downloads_bdd.py index 0f810a6c3..3549b3c61 100644 --- a/tests/end2end/features/test_downloads_bdd.py +++ b/tests/end2end/features/test_downloads_bdd.py @@ -21,10 +21,15 @@ import os import sys import shlex +import pytest import pytest_bdd as bdd bdd.scenarios('downloads.feature') +pytestmark = pytest.mark.qtwebengine_todo("Downloads not implemented yet", + run=False) + + @bdd.given("I set up a temporary download dir") def temporary_download_dir(quteproc, tmpdir): quteproc.set_setting('storage', 'prompt-download-directory', 'false') @@ -77,6 +82,7 @@ def download_open(quteproc): cmd = '{} -c pass'.format(shlex.quote(sys.executable)) quteproc.send_cmd(':download-open {}'.format(cmd)) + @bdd.when("I directly open the download") def download_open_with_prompt(quteproc): cmd = '{} -c pass'.format(shlex.quote(sys.executable)) diff --git a/tests/end2end/features/test_hints_bdd.py b/tests/end2end/features/test_hints_bdd.py index 775c347be..b5304cb74 100644 --- a/tests/end2end/features/test_hints_bdd.py +++ b/tests/end2end/features/test_hints_bdd.py @@ -17,5 +17,30 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import textwrap + +import pytest + import pytest_bdd as bdd bdd.scenarios('hints.feature') + + +@pytest.fixture(autouse=True) +def set_up_word_hints(tmpdir, quteproc): + dict_file = tmpdir / 'dict' + dict_file.write(textwrap.dedent(""" + one + two + three + four + five + six + seven + eight + nine + ten + eleven + twelve + thirteen + """)) + quteproc.set_setting('hints', 'dictionary', str(dict_file)) diff --git a/tests/end2end/features/test_invoke_bdd.py b/tests/end2end/features/test_invoke_bdd.py new file mode 100644 index 000000000..86faf8107 --- /dev/null +++ b/tests/end2end/features/test_invoke_bdd.py @@ -0,0 +1,30 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 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_bdd as bdd +bdd.scenarios('invoke.feature') + + +@bdd.when(bdd.parsers.parse("I spawn a new window")) +def invoke_with(quteproc): + """Spawn a new window via IPC call.""" + quteproc.log_summary("Create a new window") + quteproc.send_ipc([], target_arg='window') + quteproc.wait_for(category='init', module='app', + function='_open_startpage', message='Opening startpage') diff --git a/tests/end2end/features/test_marks_bdd.py b/tests/end2end/features/test_marks_bdd.py index b2777cbe4..01d9aa1ae 100644 --- a/tests/end2end/features/test_marks_bdd.py +++ b/tests/end2end/features/test_marks_bdd.py @@ -17,12 +17,17 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import pytest import pytest_bdd as bdd bdd.scenarios('marks.feature') @bdd.then(bdd.parsers.parse("the page should be scrolled to {x} {y}")) -def check_y(quteproc, x, y): +def check_y(request, quteproc, x, y): + if request.config.getoption('--qute-bdd-webengine'): + # pylint: disable=no-member + pytest.xfail(reason="QtWebEngine TODO: Sessions are not implemented") + # pylint: enable=no-member data = quteproc.get_session() pos = data['windows'][0]['tabs'][0]['history'][-1]['scroll-pos'] assert int(x) == pos['x'] diff --git a/tests/end2end/features/test_prompts_bdd.py b/tests/end2end/features/test_prompts_bdd.py index d4b2dbc8c..d98acab9a 100644 --- a/tests/end2end/features/test_prompts_bdd.py +++ b/tests/end2end/features/test_prompts_bdd.py @@ -17,10 +17,14 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import pytest import pytest_bdd as bdd bdd.scenarios('prompts.feature') +pytestmark = pytest.mark.qtwebengine_todo("Prompts are not implemented") + + @bdd.when("I load an SSL page") def load_ssl_page(quteproc, ssl_server): # We don't wait here as we can get an SSL question. @@ -33,12 +37,6 @@ def wait_ssl_page_finished_loading(quteproc, ssl_server): load_status='warn') -@bdd.when("I click the button") -def click_button(quteproc): - quteproc.send_cmd(':hint') - quteproc.send_cmd(':follow-hint a') - - @bdd.when("I wait for a prompt") def wait_for_prompt(quteproc): quteproc.wait_for(message='Entering mode KeyMode.* (reason: question ' diff --git a/tests/end2end/features/test_search_bdd.py b/tests/end2end/features/test_search_bdd.py index a2f94f569..12e5d9480 100644 --- a/tests/end2end/features/test_search_bdd.py +++ b/tests/end2end/features/test_search_bdd.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import pytest import pytest_bdd as bdd # pylint: disable=unused-import @@ -24,3 +25,5 @@ from end2end.features.test_yankpaste_bdd import init_fake_clipboard bdd.scenarios('search.feature') + +pytestmark = pytest.mark.qtwebengine_skip("Searched text is not selected...") diff --git a/tests/end2end/features/test_sessions_bdd.py b/tests/end2end/features/test_sessions_bdd.py index 0af29f414..d4eec436c 100644 --- a/tests/end2end/features/test_sessions_bdd.py +++ b/tests/end2end/features/test_sessions_bdd.py @@ -17,5 +17,9 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import pytest import pytest_bdd as bdd bdd.scenarios('sessions.feature') + + +pytestmark = pytest.mark.qtwebengine_todo("Sessions are not implemented") diff --git a/tests/end2end/features/test_tabs_bdd.py b/tests/end2end/features/test_tabs_bdd.py index a547f2f8c..bcae6d60d 100644 --- a/tests/end2end/features/test_tabs_bdd.py +++ b/tests/end2end/features/test_tabs_bdd.py @@ -19,10 +19,3 @@ import pytest_bdd as bdd bdd.scenarios('tabs.feature') - - -@bdd.given("I clean up open tabs") -def clean_open_tabs(quteproc): - quteproc.set_setting('tabs', 'last-close', 'blank') - quteproc.send_cmd(':tab-only') - quteproc.send_cmd(':tab-close') diff --git a/tests/end2end/features/urlmarks.feature b/tests/end2end/features/urlmarks.feature index 595a93dcc..8ae5aae35 100644 --- a/tests/end2end/features/urlmarks.feature +++ b/tests/end2end/features/urlmarks.feature @@ -220,6 +220,7 @@ Feature: quickmarks and bookmarks And I run :quickmark-del Then the quickmark file should not contain "nineteen http://localhost:*/data/numbers/19.txt" + @qtwebengine_todo: qute:bookmarks is not implemented yet Scenario: Listing quickmarks When I run :quickmark-add http://localhost:(port)/data/numbers/20.txt twenty And I run :quickmark-add http://localhost:(port)/data/numbers/21.txt twentyone @@ -227,6 +228,7 @@ Feature: quickmarks and bookmarks Then the page should contain the plaintext "twenty" And the page should contain the plaintext "twentyone" + @qtwebengine_todo: qute:bookmarks is not implemented yet Scenario: Listing bookmarks When I open data/title.html And I run :bookmark-add diff --git a/tests/end2end/features/yankpaste.feature b/tests/end2end/features/yankpaste.feature index a8aae1b90..e5f783e4f 100644 --- a/tests/end2end/features/yankpaste.feature +++ b/tests/end2end/features/yankpaste.feature @@ -1,9 +1,9 @@ Feature: Yanking and pasting. - :yank and :paste can be used to copy/paste the URL or title from/to the - clipboard and primary selection. + :yank, {clipboard} and {primary} can be used to copy/paste the URL or title + from/to the clipboard and primary selection. Background: - Given I run :tab-only + Given I clean up open tabs #### :yank @@ -45,63 +45,54 @@ Feature: Yanking and pasting. Then the message "Yanked URL to clipboard: http://localhost:(port)/data/title with spaces.html" should be shown And the clipboard should contain "http://localhost:(port)/data/title with spaces.html" - #### :paste + #### {clipboard} and {primary} Scenario: Pasting a URL When I put "http://localhost:(port)/data/hello.txt" into the clipboard - And I run :paste - And I wait until data/hello.txt is loaded - Then the requests should be: - data/hello.txt + And I run :open {clipboard} + Then data/hello.txt should be loaded Scenario: Pasting a URL from primary selection When selection is supported And I put "http://localhost:(port)/data/hello2.txt" into the primary selection - And I run :paste --sel - And I wait until data/hello2.txt is loaded - Then the requests should be: - data/hello2.txt + And I run :open {primary} + Then data/hello2.txt should be loaded Scenario: Pasting with empty clipboard When I put "" into the clipboard - And I run :paste + And I run :open {clipboard} (invalid command) Then the error "Clipboard is empty." should be shown Scenario: Pasting with empty selection When selection is supported And I put "" into the primary selection - And I run :paste --sel + And I run :open {primary} (invalid command) Then the error "Primary selection is empty." should be shown Scenario: Pasting with a space in clipboard When I put " " into the clipboard - And I run :paste + And I run :open {clipboard} (invalid command) Then the error "Clipboard is empty." should be shown Scenario: Pasting in a new tab - Given I open about:blank - When I run :tab-only - And I put "http://localhost:(port)/data/hello.txt" into the clipboard - And I run :paste -t + When I put "http://localhost:(port)/data/hello.txt" into the clipboard + And I run :open -t {clipboard} And I wait until data/hello.txt is loaded Then the following tabs should be open: - about:blank - data/hello.txt (active) Scenario: Pasting in a background tab - Given I open about:blank - When I run :tab-only - And I put "http://localhost:(port)/data/hello.txt" into the clipboard - And I run :paste -b + When I put "http://localhost:(port)/data/hello.txt" into the clipboard + And I run :open -b {clipboard} And I wait until data/hello.txt is loaded Then the following tabs should be open: - about:blank (active) - data/hello.txt Scenario: Pasting in a new window - Given I have a fresh instance When I put "http://localhost:(port)/data/hello.txt" into the clipboard - And I run :paste -w + And I run :open -w {clipboard} And I wait until data/hello.txt is loaded Then the session should look like: windows: @@ -119,16 +110,15 @@ Feature: Yanking and pasting. Scenario: Pasting an invalid URL When I set general -> auto-search to false And I put "foo bar" into the clipboard - And I run :paste + And I run :open {clipboard} Then the error "Invalid URL" should be shown Scenario: Pasting multiple urls in a new tab - Given I have a fresh instance When I put the following lines into the clipboard: http://localhost:(port)/data/hello.txt http://localhost:(port)/data/hello2.txt http://localhost:(port)/data/hello3.txt - And I run :paste -t + And I run :open -t {clipboard} And I wait until data/hello.txt is loaded And I wait until data/hello2.txt is loaded And I wait until data/hello3.txt is loaded @@ -139,40 +129,37 @@ Feature: Yanking and pasting. - data/hello3.txt Scenario: Pasting multiline text - Given I have a fresh instance - When I set searchengines -> DEFAULT to http://localhost:(port)/data/hello.txt?q={} + When I set general -> auto-search to true + And I set searchengines -> DEFAULT to http://localhost:(port)/data/hello.txt?q={} And I put the following lines into the clipboard: this url: http://qutebrowser.org should not open - And I run :paste -t + And I run :open -t {clipboard} And I wait until data/hello.txt?q=this%20url%3A%0Ahttp%3A//qutebrowser.org%0Ashould%20not%20open is loaded Then the following tabs should be open: - about:blank - data/hello.txt?q=this%20url%3A%0Ahttp%3A//qutebrowser.org%0Ashould%20not%20open (active) Scenario: Pasting multiline whose first line looks like a URI - Given I open about:blank - When I run :tab-only - When I set searchengines -> DEFAULT to http://localhost:(port)/data/hello.txt?q={} + When I set general -> auto-search to true + And I set searchengines -> DEFAULT to http://localhost:(port)/data/hello.txt?q={} And I put the following lines into the clipboard: text: should open as search - And I run :paste -t + And I run :open -t {clipboard} And I wait until data/hello.txt?q=text%3A%0Ashould%20open%0Aas%20search is loaded Then the following tabs should be open: - about:blank - data/hello.txt?q=text%3A%0Ashould%20open%0Aas%20search (active) Scenario: Pasting multiple urls in a background tab - Given I open about:blank - When I run :tab-only - And I put the following lines into the clipboard: + When I put the following lines into the clipboard: http://localhost:(port)/data/hello.txt http://localhost:(port)/data/hello2.txt http://localhost:(port)/data/hello3.txt - And I run :paste -b + And I run :open -b {clipboard} And I wait until data/hello.txt is loaded And I wait until data/hello2.txt is loaded And I wait until data/hello3.txt is loaded @@ -183,12 +170,11 @@ Feature: Yanking and pasting. - data/hello3.txt Scenario: Pasting multiple urls in new windows - Given I have a fresh instance When I put the following lines into the clipboard: http://localhost:(port)/data/hello.txt http://localhost:(port)/data/hello2.txt http://localhost:(port)/data/hello3.txt - And I run :paste -w + And I run :open -w {clipboard} And I wait until data/hello.txt is loaded And I wait until data/hello2.txt is loaded And I wait until data/hello3.txt is loaded @@ -216,93 +202,65 @@ Feature: Yanking and pasting. url: http://localhost:*/data/hello3.txt Scenario: Pasting multiple urls with an empty one - When I open about:blank And I put "http://localhost:(port)/data/hello.txt\n\nhttp://localhost:(port)/data/hello2.txt" into the clipboard - And I run :paste -t + And I run :open -t {clipboard} Then no crash should happen Scenario: Pasting multiple urls with an almost empty one - When I open about:blank And I put "http://localhost:(port)/data/hello.txt\n \nhttp://localhost:(port)/data/hello2.txt" into the clipboard - And I run :paste -t + And I run :open -t {clipboard} Then no crash should happen - #### :paste-primary + #### :insert-text - Scenario: Pasting the primary selection into an empty text field - When selection is supported - And I open data/paste_primary.html - And I put "Hello world" into the primary selection - # Click the text field - And I run :hint all - And I run :follow-hint a + @qtwebengine_todo: :insert-text is not implemented yet + Scenario: Inserting text into an empty text field + When I open data/paste_primary.html + And I run :click-element id qute-textarea And I wait for "Clicked editable element!" in the log - And I run :paste-primary + And I run :insert-text Hello world # Compare Then the text field should contain "Hello world" - Scenario: Pasting the primary selection into a text field at specific position - When selection is supported - And I open data/paste_primary.html + @qtwebengine_todo: :insert-text is not implemented yet + Scenario: Inserting text into a text field at specific position + When I open data/paste_primary.html And I set the text field to "one two three four" - And I put " Hello world" into the primary selection - # Click the text field - And I run :hint all - And I run :follow-hint a + And I run :click-element id qute-textarea And I wait for "Clicked editable element!" in the log - # Move to the beginning and two words to the right + # Move to the beginning and two characters to the right And I press the keys "" - And I press the key "" - And I press the key "" - And I run :paste-primary + And I press the key "" + And I press the key "" + And I run :insert-text Hello world # Compare - Then the text field should contain "one two Hello world three four" + Then the text field should contain "onHello worlde two three four" - Scenario: Pasting the primary selection into a text field with undo - When selection is supported - And I open data/paste_primary.html - # Click the text field - And I run :hint all - And I run :follow-hint a + @qtwebengine_todo: :insert-text is not implemented yet + Scenario: Inserting text into a text field with undo + When I open data/paste_primary.html + And I run :click-element id qute-textarea And I wait for "Clicked editable element!" in the log # Paste and undo - And I put "This text should be undone" into the primary selection - And I run :paste-primary + And I run :insert-text This text should be undone And I press the key "" # Paste final text - And I put "This text should stay" into the primary selection - And I run :paste-primary + And I run :insert-text This text should stay # Compare Then the text field should contain "This text should stay" - Scenario: Pasting the primary selection without a focused field - When selection is supported - And I open data/paste_primary.html - And I put "test" into the primary selection + @qtwebengine_todo: :insert-text is not implemented yet + Scenario: Inserting text without a focused field + When I open data/paste_primary.html And I run :enter-mode insert - And I run :paste-primary + And I run :insert-text test Then the error "No element focused!" should be shown - Scenario: Pasting the primary selection with a read-only field - When selection is supported - And I open data/paste_primary.html - # Click the text field - And I run :hint all - And I run :follow-hint s + @qtwebengine_todo: :insert-text is not implemented yet + Scenario: Inserting text with a read-only field + When I open data/paste_primary.html + And I run :click-element id qute-textarea-noedit And I wait for "Clicked non-editable element!" in the log - And I put "test" into the primary selection And I run :enter-mode insert - And I run :paste-primary + And I run :insert-text test Then the error "Focused element is not editable!" should be shown - - Scenario: :paste-primary without primary selection supported - When selection is not supported - And I open data/paste_primary.html - And I put "Hello world" into the clipboard - # Click the text field - And I run :hint all - And I run :follow-hint a - And I wait for "Clicked editable element!" in the log - And I run :paste-primary - # Compare - Then the text field should contain "Hello world" diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index bff71ce49..7a6e83413 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -140,17 +140,14 @@ class QuteProc(testprocess.Process): """A running qutebrowser process used for tests. Attributes: - _delay: Delay to wait between commands. _ipc_socket: The IPC socket of the started instance. - _httpbin: The HTTPBin webserver. _webengine: Whether to use QtWebEngine basedir: The base directory for this instance. + request: The request object for the current test. _focus_ready: Whether the main window got focused. _load_ready: Whether the about:blank page got loaded. - _profile: If True, do profiling of the subprocesses. _instance_id: A unique ID for this QuteProc instance _run_counter: A counter to get a unique ID for each run. - _config: The pytest config object Signals: got_error: Emitted when there was an error log line. @@ -161,20 +158,16 @@ class QuteProc(testprocess.Process): KEYS = ['timestamp', 'loglevel', 'category', 'module', 'function', 'line', 'message'] - def __init__(self, httpbin, delay, *, webengine=False, profile=False, - config=None, parent=None): + def __init__(self, request, *, parent=None): super().__init__(parent) - self._webengine = webengine - self._profile = profile - self._delay = delay - self._httpbin = httpbin + self._webengine = request.config.getoption('--qute-bdd-webengine') self._ipc_socket = None self.basedir = None self._focus_ready = False self._load_ready = False self._instance_id = next(instance_counter) self._run_counter = itertools.count() - self._config = config + self.request = request def _is_ready(self, what): """Called by _parse_line if loading/focusing is done. @@ -196,12 +189,15 @@ class QuteProc(testprocess.Process): except testprocess.InvalidLine: if not line.strip(): return None + elif 'Running without the SUID sandbox!' in line: + # QtWebEngine error + return None elif is_ignored_qt_message(line): return None else: raise - log_line.use_color = self._config.getoption('--color') != 'no' + log_line.use_color = self.request.config.getoption('--color') != 'no' self._log(log_line) start_okay_message_load = ( @@ -241,15 +237,16 @@ class QuteProc(testprocess.Process): return log_line def _executable_args(self): + profile = self.request.config.getoption('--qute-profile-subprocs') if hasattr(sys, 'frozen'): - if self._profile: + if profile: raise Exception("Can't profile with sys.frozen!") executable = os.path.join(os.path.dirname(sys.executable), 'qutebrowser') args = [] else: executable = sys.executable - if self._profile: + if profile: profile_dir = os.path.join(os.getcwd(), 'prof') profile_id = '{}_{}'.format(self._instance_id, next(self._run_counter)) @@ -280,9 +277,10 @@ class QuteProc(testprocess.Process): if path.startswith('about:') or path.startswith('qute:'): return path else: + httpbin = self.request.getfuncargvalue('httpbin') return '{}://localhost:{}/{}'.format( 'https' if https else 'http', - self._httpbin.port if port is None else port, + httpbin.port if port is None else port, path if path != '/' else '') def wait_for_js(self, message): @@ -295,6 +293,15 @@ class QuteProc(testprocess.Process): function='javaScriptConsoleMessage', message='[*] {}'.format(message)) + def wait_for(self, timeout=None, **kwargs): + """Extend wait_for to add divisor if a test is xfailing.""" + xfail = self.request.node.get_marker('xfail') + if xfail and xfail.args[0]: + kwargs['divisor'] = 10 + else: + kwargs['divisor'] = 1 + return super().wait_for(timeout=timeout, **kwargs) + def _is_error_logline(self, msg): """Check if the given LogLine is some kind of error message.""" is_js_error = (msg.category == 'js' and @@ -329,26 +336,29 @@ class QuteProc(testprocess.Process): """Adjust some qutebrowser settings after starting.""" settings = [ ('ui', 'message-timeout', '0'), - ('network', 'ssl-strict', 'false'), ('general', 'auto-save-interval', '0'), + ('general', 'new-instance-open-target.window', 'last-opened') ] + if not self._webengine: + settings.append(('network', 'ssl-strict', 'false')) + for sect, opt, value in settings: self.set_setting(sect, opt, value) - def after_test(self, did_fail): - """Handle unexpected/skip logging and clean up after each test. - - Args: - did_fail: Set if the main test failed already, then logged errors - are ignored. - """ + def after_test(self): + """Handle unexpected/skip logging and clean up after each test.""" __tracebackhide__ = True bad_msgs = [msg for msg in self._data if self._is_error_logline(msg) and not msg.expected] - if did_fail: - super().after_test() - return + try: + call = self.request.node.rep_call + except AttributeError: + pass + else: + if call.failed or hasattr(call, 'wasxfail'): + super().after_test() + return try: if bad_msgs: @@ -362,6 +372,16 @@ class QuteProc(testprocess.Process): finally: super().after_test() + def send_ipc(self, commands, target_arg=''): + """Send a raw command to the running IPC socket.""" + delay = self.request.config.getoption('--qute-delay') + time.sleep(delay / 1000) + + assert self._ipc_socket is not None + ipc.send_to_running_instance(self._ipc_socket, commands, target_arg) + self.wait_for(category='ipc', module='ipc', function='on_ready_read', + message='Read from socket *') + def send_cmd(self, command, count=None, invalid=False, *, escape=True): """Send a command to the running qutebrowser instance. @@ -376,18 +396,13 @@ class QuteProc(testprocess.Process): summary += ' (count {})'.format(count) self.log_summary(summary) - assert self._ipc_socket is not None - - time.sleep(self._delay / 1000) - if escape: command = command.replace('\\', r'\\') if count is not None: command = ':{}:{}'.format(count, command.lstrip(':')) - ipc.send_to_running_instance(self._ipc_socket, [command], - target_arg='') + self.send_ipc([command]) if not invalid: self.wait_for(category='commands', module='command', function='run', message='command called: *') @@ -415,18 +430,22 @@ class QuteProc(testprocess.Process): yield self.set_setting(sect, opt, old_value) - def open_path(self, path, *, new_tab=False, new_window=False, port=None, - https=False, wait=True): + def open_path(self, path, *, new_tab=False, new_window=False, as_url=False, + port=None, https=False, wait=True): """Open the given path on the local webserver in qutebrowser.""" url = self.path_to_url(path, port=port, https=https) - self.open_url(url, new_tab=new_tab, new_window=new_window, wait=wait) + self.open_url(url, new_tab=new_tab, new_window=new_window, + as_url=as_url, wait=wait) - def open_url(self, url, *, new_tab=False, new_window=False, wait=True): + def open_url(self, url, *, new_tab=False, new_window=False, as_url=False, + wait=True): """Open the given url in qutebrowser.""" if new_tab and new_window: raise ValueError("new_tab and new_window given!") - if new_tab: + if as_url: + self.send_cmd(url, invalid=True) + elif new_tab: self.send_cmd(':open -t ' + url) elif new_window: self.send_cmd(':open -w ' + url) @@ -515,7 +534,7 @@ class QuteProc(testprocess.Process): """Press the given keys using :fake-key.""" self.send_cmd(':fake-key -g "{}"'.format(keys)) - def click_element(self, text): + def click_element_by_text(self, text): """Click the element with the given text.""" # Use Javascript and XPath to find the right element, use console.log # to return an error (no element found, ambiguous element) @@ -590,11 +609,8 @@ def _xpath_escape(text): @pytest.yield_fixture(scope='module') def quteproc_process(qapp, httpbin, request): """Fixture for qutebrowser process which is started once per file.""" - delay = request.config.getoption('--qute-delay') - profile = request.config.getoption('--qute-profile-subprocs') - webengine = request.config.getoption('--qute-bdd-webengine') - proc = QuteProc(httpbin, delay, webengine=webengine, profile=profile, - config=request.config) + # Passing request so it has an initial config + proc = QuteProc(request) proc.start() yield proc proc.terminate() @@ -605,19 +621,17 @@ def quteproc(quteproc_process, httpbin, request): """Per-test qutebrowser fixture which uses the per-file process.""" request.node._quteproc_log = quteproc_process.captured_log quteproc_process.before_test() + quteproc_process.request = request yield quteproc_process - quteproc_process.after_test(did_fail=request.node.rep_call.failed) + quteproc_process.after_test() @pytest.yield_fixture def quteproc_new(qapp, httpbin, request): """Per-test qutebrowser process to test invocations.""" - delay = request.config.getoption('--qute-delay') - profile = request.config.getoption('--qute-profile-subprocs') - webengine = request.config.getoption('--qute-bdd-webengine') - proc = QuteProc(httpbin, delay, webengine=webengine, profile=profile, - config=request.config) + proc = QuteProc(request) request.node._quteproc_log = proc.captured_log # Not calling before_test here as that would start the process yield proc - proc.after_test(did_fail=request.node.rep_call.failed) + proc.after_test() + proc.terminate() diff --git a/tests/end2end/fixtures/test_quteprocess.py b/tests/end2end/fixtures/test_quteprocess.py index 4c9f74f63..80094c588 100644 --- a/tests/end2end/fixtures/test_quteprocess.py +++ b/tests/end2end/fixtures/test_quteprocess.py @@ -29,27 +29,86 @@ from end2end.fixtures import quteprocess, testprocess from qutebrowser.utils import log +class FakeRepCall: + + """Fake for request.node.rep_call.""" + + def __init__(self): + self.failed = False + + +class FakeConfig: + + """Fake for request.config.""" + + ARGS = { + '--qute-delay': 0, + '--color': True, + } + + def getoption(self, name): + return self.ARGS[name] + + +class FakeNode: + + """Fake for request.node.""" + + def __init__(self, call): + self.rep_call = call + + def get_marker(self, _name): + return None + + +class FakeRequest: + + """Fake for request.""" + + def __init__(self, node, config, httpbin): + self.node = node + self.config = config + self._httpbin = httpbin + + def getfuncargvalue(self, name): + assert name == 'httpbin' + return self._httpbin + + +@pytest.fixture +def request_mock(quteproc, monkeypatch, httpbin): + """Patch out a pytest request.""" + fake_call = FakeRepCall() + fake_config = FakeConfig() + fake_node = FakeNode(fake_call) + fake_request = FakeRequest(fake_node, fake_config, httpbin) + assert not hasattr(fake_request.node.rep_call, 'wasxfail') + monkeypatch.setattr(quteproc, 'request', fake_request) + return fake_request + + @pytest.mark.parametrize('cmd', [ ':message-error test', ':jseval console.log("[FAIL] test");' ]) -def test_quteproc_error_message(qtbot, quteproc, cmd): +def test_quteproc_error_message(qtbot, quteproc, cmd, request_mock): """Make sure the test fails with an unexpected error message.""" with qtbot.waitSignal(quteproc.got_error): quteproc.send_cmd(cmd) # Usually we wouldn't call this from inside a test, but here we force the # error to occur during the test rather than at teardown time. with pytest.raises(pytest.fail.Exception): - quteproc.after_test(did_fail=False) + quteproc.after_test() -def test_quteproc_error_message_did_fail(qtbot, quteproc): +def test_quteproc_error_message_did_fail(qtbot, quteproc, request_mock): """Make sure the test does not fail on teardown if the main test failed.""" + request_mock.node.rep_call.failed = True with qtbot.waitSignal(quteproc.got_error): quteproc.send_cmd(':message-error test') # Usually we wouldn't call this from inside a test, but here we force the # error to occur during the test rather than at teardown time. - quteproc.after_test(did_fail=True) + quteproc.after_test() def test_quteproc_skip_via_js(qtbot, quteproc): @@ -59,7 +118,7 @@ def test_quteproc_skip_via_js(qtbot, quteproc): # Usually we wouldn't call this from inside a test, but here we force # the error to occur during the test rather than at teardown time. - quteproc.after_test(did_fail=False) + quteproc.after_test() assert str(excinfo.value) == 'test' @@ -83,7 +142,7 @@ def test_quteprocess_quitting(qtbot, quteproc_process): with qtbot.waitSignal(quteproc_process.proc.finished, timeout=15000): quteproc_process.send_cmd(':quit') with pytest.raises(testprocess.ProcessExited): - quteproc_process.after_test(did_fail=False) + quteproc_process.after_test() @pytest.mark.parametrize('data, attrs', [ @@ -240,28 +299,28 @@ def test_log_line_no_match(): quteprocess.LogLine("Hello World!") -class TestClickElement: +class TestClickElementByText: @pytest.fixture(autouse=True) def open_page(self, quteproc): quteproc.open_path('data/click_element.html') def test_click_element(self, quteproc): - quteproc.click_element('Test Element') + quteproc.click_element_by_text('Test Element') quteproc.wait_for_js('click_element clicked') def test_click_special_chars(self, quteproc): - quteproc.click_element('"Don\'t", he shouted') + quteproc.click_element_by_text('"Don\'t", he shouted') quteproc.wait_for_js('click_element special chars') def test_duplicate(self, quteproc): with pytest.raises(ValueError) as excinfo: - quteproc.click_element('Duplicate') + quteproc.click_element_by_text('Duplicate') assert 'not unique' in str(excinfo.value) def test_nonexistent(self, quteproc): with pytest.raises(ValueError) as excinfo: - quteproc.click_element('no element exists with this text') + quteproc.click_element_by_text('no element exists with this text') assert 'No element' in str(excinfo.value) @@ -281,6 +340,6 @@ def test_xpath_escape(string, expected): 'foo"bar', # Make sure a " is preserved ]) def test_set(quteproc, value): - quteproc.set_setting('network', 'accept-language', value) - read_back = quteproc.get_setting('network', 'accept-language') + quteproc.set_setting('general', 'default-encoding', value) + read_back = quteproc.get_setting('general', 'default-encoding') assert read_back == value diff --git a/tests/end2end/fixtures/test_testprocess.py b/tests/end2end/fixtures/test_testprocess.py index 4ead5f238..503de462d 100644 --- a/tests/end2end/fixtures/test_testprocess.py +++ b/tests/end2end/fixtures/test_testprocess.py @@ -159,12 +159,12 @@ def test_custom_environment(pyproc): @pytest.mark.posix -def test_custom_environment_no_system(monkeypatch, pyproc): - """When env=... is given, no system environment should be present.""" - monkeypatch.setenv('QUTE_TEST_ENV', 'blah') - pyproc.code = 'import os; print(os.environ.get("QUTE_TEST_ENV", "None"))' +def test_custom_environment_system_env(monkeypatch, pyproc): + """When env=... is given, the system environment should be present.""" + monkeypatch.setenv('QUTE_TEST_ENV', 'blubb') + pyproc.code = 'import os; print(os.environ["QUTE_TEST_ENV"])' pyproc.start(env={}) - pyproc.wait_for(data='None') + pyproc.wait_for(data='blubb') class TestWaitFor: diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index ed9de0f85..cb45671ad 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -251,18 +251,11 @@ class Process(QObject): if args is None: args = self._default_args() - if env is None: - procenv = QProcessEnvironment.systemEnvironment() - else: - procenv = QProcessEnvironment() + procenv = QProcessEnvironment.systemEnvironment() + if env is not None: for k, v in env.items(): procenv.insert(k, v) - passthrough_vars = ['DISPLAY', 'HOME'] # so --no-xvfb works - for var in passthrough_vars: - if var in os.environ: - procenv.insert(var, os.environ[var]) - self.proc.readyRead.connect(self.read_log) self.proc.setProcessEnvironment(procenv) self.proc.start(executable, exec_args + args) @@ -431,7 +424,7 @@ class Process(QObject): pass def wait_for(self, timeout=None, *, override_waited_for=False, - do_skip=False, **kwargs): + do_skip=False, divisor=1, **kwargs): """Wait until a given value is found in the data. Keyword arguments to this function get interpreted as attributes of the @@ -443,6 +436,7 @@ class Process(QObject): override_waited_for: If set, gets triggered by previous messages again. do_skip: If set, call pytest.skip on a timeout. + divisor: A factor to decrease the timeout by. Return: The matched line. @@ -456,6 +450,9 @@ class Process(QObject): timeout = 15000 else: timeout = 5000 + + timeout /= divisor + if not kwargs: raise TypeError("No keyword arguments given!") for key in kwargs: diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 0d9357dff..1e4e25826 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -73,9 +73,10 @@ class Request(testprocess.Line): '/status/404': [http.client.NOT_FOUND], '/cookies/set': [http.client.FOUND], } + default_statuses = [http.client.OK, http.client.NOT_MODIFIED] sanitized = QUrl('http://localhost' + self.path).path() # Remove ?foo - expected_statuses = path_to_statuses.get(sanitized, [http.client.OK]) + expected_statuses = path_to_statuses.get(sanitized, default_statuses) assert self.status in expected_statuses def __eq__(self, other): diff --git a/tests/end2end/test_dirbrowser.py b/tests/end2end/test_dirbrowser.py index 52ef10817..2359020d7 100644 --- a/tests/end2end/test_dirbrowser.py +++ b/tests/end2end/test_dirbrowser.py @@ -29,6 +29,10 @@ from PyQt5.QtCore import QUrl from qutebrowser.utils import urlutils +pytestmark = pytest.mark.qtwebengine_skip("Title is empty when parsing for " + "some reason?") + + class DirLayout: """Provide a fake directory layout to test dirbrowser.""" @@ -179,7 +183,7 @@ def test_enter_folder_smoke(dir_layout, quteproc): @pytest.mark.parametrize('folder', DirLayout.layout_folders()) def test_enter_folder(dir_layout, quteproc, folder): quteproc.open_url(dir_layout.file_url()) - quteproc.click_element(text=folder) + quteproc.click_element_by_text(text=folder) expected_url = urlutils.file_url(dir_layout.path(folder)) quteproc.wait_for_load_finished_url(expected_url) page = parse(quteproc) diff --git a/tests/end2end/test_hints_html.py b/tests/end2end/test_hints_html.py index 58a19eb21..c5b2fd6bd 100644 --- a/tests/end2end/test_hints_html.py +++ b/tests/end2end/test_hints_html.py @@ -26,6 +26,7 @@ import yaml import pytest import bs4 import textwrap +import collections def collect_tests(): @@ -35,53 +36,86 @@ def collect_tests(): return files -@pytest.mark.parametrize('test_name', collect_tests()) -@pytest.mark.parametrize('zoom_text_only', [True, False]) -@pytest.mark.parametrize('zoom_level', [100, 66, 33]) -@pytest.mark.parametrize('find_implementation', ['javascript', 'python']) -def test_hints(test_name, zoom_text_only, zoom_level, find_implementation, - quteproc): +ParsedFile = collections.namedtuple('ParsedFile', ['target', + 'qtwebengine_todo']) + + +class InvalidFile(Exception): + + def __init__(self, test_name, msg): + super().__init__("Invalid comment found in {}, please read " + "tests/end2end/data/hints/html/README.md - {}".format( + test_name, msg)) + + +def _parse_file(test_name): + """Parse the given HTML file.""" file_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data', 'hints', 'html', test_name) - url_path = 'data/hints/html/{}'.format(test_name) - quteproc.open_path(url_path) - with open(file_path, 'r', encoding='utf-8') as html: soup = bs4.BeautifulSoup(html, 'html.parser') comment = soup.find(text=lambda text: isinstance(text, bs4.Comment)) if comment is None: - pytest.fail("No comment found in {}, please read " - "tests/end2end/data/hints/html/README.md".format( - test_name)) + raise InvalidFile(test_name, "no comment found") - parsed = yaml.load(comment) - if not isinstance(parsed, dict): - pytest.fail("Invalid comment found in {}, please read " - "tests/end2end/data/hints/html/README.md - " - "expected yaml dict but got {}".format( - test_name, type(parsed).__name__)) + data = yaml.load(comment) + if not isinstance(data, dict): + raise InvalidFile(test_name, "expected yaml dict but got {}".format( + type(data).__name__)) - if set(parsed.keys()) != {'target'}: - pytest.fail("Invalid comment found in {}, please read " - "tests/end2end/data/hints/html/README.md - " - "expected key 'target' but found {}".format( - test_name, ', '.join(set(parsed.keys())))) + allowed_keys = {'target', 'qtwebengine_todo'} + if not set(data.keys()).issubset(allowed_keys): + raise InvalidFile(test_name, "expected keys {} but found {}".format( + ', '.join(allowed_keys), + ', '.join(set(data.keys())))) + + if 'target' not in data: + raise InvalidFile(test_name, "'target' key not found") + + qtwebengine_todo = data.get('qtwebengine_todo', None) + + return ParsedFile(target=data['target'], qtwebengine_todo=qtwebengine_todo) + + +@pytest.mark.parametrize('test_name', collect_tests()) +@pytest.mark.parametrize('zoom_text_only', [True, False]) +@pytest.mark.parametrize('zoom_level', [100, 66, 33]) +@pytest.mark.parametrize('find_implementation', ['javascript', 'python']) +def test_hints(test_name, zoom_text_only, zoom_level, find_implementation, + quteproc, request): + webengine = bool(request.config.getoption('--qute-bdd-webengine')) + if zoom_text_only and webengine: + pytest.skip("QtWebEngine doesn't have zoom-text-only") + if find_implementation == 'python' and webengine: + pytest.skip("QtWebEngine doesn't have a python find implementation") + + parsed = _parse_file(test_name) + if parsed.qtwebengine_todo is not None and webengine: + # pylint: disable=no-member + pytest.xfail("QtWebEngine TODO: {}".format(parsed.qtwebengine_todo)) + # pylint: enable=no-member + + url_path = 'data/hints/html/{}'.format(test_name) + quteproc.open_path(url_path) # setup - quteproc.set_setting('ui', 'zoom-text-only', str(zoom_text_only)) - quteproc.set_setting('hints', 'find-implementation', find_implementation) + if not webengine: + quteproc.set_setting('ui', 'zoom-text-only', str(zoom_text_only)) + quteproc.set_setting('hints', 'find-implementation', + find_implementation) quteproc.send_cmd(':zoom {}'.format(zoom_level)) # follow hint quteproc.send_cmd(':hint links normal') quteproc.wait_for(message='hints: a', category='hints') quteproc.send_cmd(':follow-hint a') - quteproc.wait_for_load_finished('data/' + parsed['target']) + quteproc.wait_for_load_finished('data/' + parsed.target) # reset quteproc.send_cmd(':zoom 100') - quteproc.set_setting('ui', 'zoom-text-only', 'false') - quteproc.set_setting('hints', 'find-implementation', 'javascript') + if not webengine: + quteproc.set_setting('ui', 'zoom-text-only', 'false') + quteproc.set_setting('hints', 'find-implementation', 'javascript') def test_word_hints_issue1393(quteproc, tmpdir): diff --git a/tests/end2end/test_insert_mode.py b/tests/end2end/test_insert_mode.py index c8a7f32de..31a9be5ee 100644 --- a/tests/end2end/test_insert_mode.py +++ b/tests/end2end/test_insert_mode.py @@ -25,30 +25,39 @@ import json import pytest -@pytest.mark.parametrize('file_name, source, input_text, auto_insert', [ - ('textarea.html', 'clipboard', 'qutebrowser', 'false'), - ('textarea.html', 'keypress', 'superqutebrowser', 'false'), - ('input.html', 'clipboard', 'amazingqutebrowser', 'false'), - ('input.html', 'keypress', 'awesomequtebrowser', 'false'), - ('autofocus.html', 'keypress', 'cutebrowser', 'true'), +@pytest.mark.parametrize(['file_name', 'elem_id', 'source', 'input_text', + 'auto_insert'], [ + ('textarea.html', 'qute-textarea', 'clipboard', 'qutebrowser', 'false'), + ('textarea.html', 'qute-textarea', 'keypress', 'superqutebrowser', + 'false'), + ('input.html', 'qute-input', 'clipboard', 'amazingqutebrowser', 'false'), + ('input.html', 'qute-input', 'keypress', 'awesomequtebrowser', 'false'), + ('autofocus.html', 'qute-input-autofocus', 'keypress', 'cutebrowser', + 'true'), ]) -def test_insert_mode(file_name, source, input_text, auto_insert, quteproc): +def test_insert_mode(file_name, elem_id, source, input_text, auto_insert, + quteproc, request): url_path = 'data/insert_mode_settings/html/{}'.format(file_name) quteproc.open_path(url_path) quteproc.set_setting('input', 'auto-insert-mode', auto_insert) - quteproc.send_cmd(':hint all') - quteproc.send_cmd(':follow-hint a') + quteproc.send_cmd(':click-element id {}'.format(elem_id)) quteproc.wait_for(message='Clicked editable element!') quteproc.send_cmd(':debug-set-fake-clipboard') if source == 'keypress': quteproc.press_keys(input_text) elif source == 'clipboard': + if request.config.getoption('--qute-bdd-webengine'): + # pylint: disable=no-member + pytest.xfail(reason="QtWebEngine TODO: :insert-text is not " + "implemented") + # pylint: enable=no-member quteproc.send_cmd(':debug-set-fake-clipboard "{}"'.format(input_text)) - quteproc.send_cmd(':paste-primary') + quteproc.send_cmd(':insert-text {clipboard}') quteproc.send_cmd(':hint all') + quteproc.wait_for(message='hints: *') quteproc.send_cmd(':follow-hint a') quteproc.wait_for(message='Clicked editable element!') quteproc.send_cmd(':enter-mode caret') @@ -73,6 +82,7 @@ def test_auto_leave_insert_mode(quteproc): quteproc.press_keys('abcd') quteproc.send_cmd(':hint all') + quteproc.wait_for(message='hints: *') # Select the disabled input box to leave insert mode quteproc.send_cmd(':follow-hint s') diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index d34859410..bdb4e11da 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -98,9 +98,6 @@ def test_ascii_locale(httpbin, tmpdir, quteproc_new): quteproc_new.wait_for(category='downloads', message='Opening * with [*python*]') - quteproc_new.send_cmd(':quit') - quteproc_new.wait_for_quit() - assert len(tmpdir.listdir()) == 1 assert (tmpdir / '?-issue908.bin').exists() @@ -110,3 +107,19 @@ def test_no_loglines(quteproc_new): quteproc_new.start(args=['--temp-basedir', '--loglines=0'] + BASE_ARGS) quteproc_new.open_path('qute:log') assert quteproc_new.get_content() == 'Log output was disabled.' + + +@pytest.mark.not_frozen +@pytest.mark.parametrize('level', ['1', '2']) +def test_optimize(quteproc_new, capfd, level): + quteproc_new.start(args=['--temp-basedir'] + BASE_ARGS, + env={'PYTHONOPTIMIZE': level}) + if level == '2': + msg = ("Running on optimize level higher than 1, unexpected behavior " + "may occur.") + line = quteproc_new.wait_for(message=msg) + line.expected = True + + # Waiting for quit to make sure no other warning is emitted + quteproc_new.send_cmd(':quit') + quteproc_new.wait_for_quit() diff --git a/tests/end2end/test_mhtml_e2e.py b/tests/end2end/test_mhtml_e2e.py index edd760a41..91ed693f6 100644 --- a/tests/end2end/test_mhtml_e2e.py +++ b/tests/end2end/test_mhtml_e2e.py @@ -27,6 +27,10 @@ import collections import pytest +pytestmark = pytest.mark.qtwebengine_todo("mhtml downloads are not " + "implemented") + + def collect_tests(): basedir = os.path.dirname(__file__) datadir = os.path.join(basedir, 'data', 'downloads', 'mhtml') diff --git a/tests/end2end/test_smoke.py b/tests/end2end/test_smoke.py index 1a579cc02..1e27cf13f 100644 --- a/tests/end2end/test_smoke.py +++ b/tests/end2end/test_smoke.py @@ -26,6 +26,7 @@ import signal import pytest + @pytest.mark.parametrize('cmd', [':quit', ':later 500 quit']) def test_smoke(cmd, capfd): if hasattr(sys, 'frozen'): diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 749f0d4f0..53344d5fe 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -41,7 +41,7 @@ from qutebrowser.browser.webkit import cookies from qutebrowser.misc import savemanager from qutebrowser.keyinput import modeman -from PyQt5.QtCore import PYQT_VERSION, QEvent, QSize, Qt +from PyQt5.QtCore import PYQT_VERSION, pyqtSignal, QEvent, QSize, Qt, QObject from PyQt5.QtGui import QKeyEvent from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout from PyQt5.QtNetwork import QNetworkCookieJar @@ -72,6 +72,36 @@ class WinRegistryHelper: del objreg.window_registry[win_id] +class CallbackChecker(QObject): + + """Check if a value provided by a callback is the expected one.""" + + got_result = pyqtSignal(object) + UNSET = object() + + def __init__(self, qtbot, parent=None): + super().__init__(parent) + self._qtbot = qtbot + self._result = self.UNSET + + def callback(self, result): + """Callback which can be passed to runJavaScript.""" + self._result = result + self.got_result.emit(result) + + def check(self, expected): + """Wait until the JS result arrived and compare it.""" + if self._result is self.UNSET: + with self._qtbot.waitSignal(self.got_result): + pass + assert self._result == expected + + +@pytest.fixture +def callback_checker(qtbot): + return CallbackChecker(qtbot) + + class FakeStatusBar(QWidget): """Fake statusbar to test progressbar sizing.""" @@ -126,7 +156,7 @@ def tab_registry(win_registry): @pytest.fixture -def fake_web_tab(stubs, tab_registry, qapp): +def fake_web_tab(stubs, tab_registry, mode_manager, qapp): """Fixture providing the FakeWebTab *class*.""" if PYQT_VERSION < 0x050600: pytest.skip('Causes segfaults, see #1638') @@ -403,7 +433,7 @@ def fake_args(): @pytest.yield_fixture def mode_manager(win_registry, config_stub, qapp): - config_stub.data = {'input': {'forward-unbound-keys': 'auto'}} + config_stub.data.update({'input': {'forward-unbound-keys': 'auto'}}) mm = modeman.ModeManager(0) objreg.register('mode-manager', mm, scope='window', window=0) yield mm diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 7cb15c1c6..169190853 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -29,8 +29,7 @@ from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget -from qutebrowser.browser import browsertab -from qutebrowser.browser.webkit import history +from qutebrowser.browser import browsertab, history from qutebrowser.config import configexc from qutebrowser.utils import usertypes, utils from qutebrowser.mainwindow import mainwindow @@ -81,7 +80,7 @@ class FakeWebFrame: """ def __init__(self, geometry=None, *, scroll=None, plaintext=None, - html=None, parent=None, zoom=1.0, document_element=None): + html=None, parent=None, zoom=1.0): """Constructor. Args: @@ -90,7 +89,6 @@ class FakeWebFrame: plaintext: Return value of toPlainText html: Return value of tohtml. zoom: The zoom factor. - document_element: The documentElement() to return parent: The parent frame. """ if scroll is None: @@ -102,7 +100,6 @@ class FakeWebFrame: self.toPlainText = mock.Mock(return_value=plaintext) self.toHtml = mock.Mock(return_value=html) self.zoomFactor = mock.Mock(return_value=zoom) - self.documentElement = mock.Mock(return_value=document_element) def findFirstElement(self, selector): if selector == '*:focus': @@ -256,7 +253,8 @@ class FakeWebTab(browsertab.AbstractTab): wrapped = QWidget() self._layout.wrap(self, wrapped) - def url(self): + def url(self, requested=False): + assert not requested return self._url def title(self): diff --git a/tests/manual/hints/hide_unmatched_rapid_hints.html b/tests/manual/hints/hide_unmatched_rapid_hints.html new file mode 100644 index 000000000..d9535733f --- /dev/null +++ b/tests/manual/hints/hide_unmatched_rapid_hints.html @@ -0,0 +1,25 @@ + + + + + Hide unmatched rapid hints + + +

When hints -> hide-unmatched-rapid-hints is set to true (default), rapid hints behave like normal hints, i.e. unmatched hints will be hidden as you type. Setting the option to false will disable hiding in rapid mode, which is sometimes useful (see #1799).

+

Note that when hinting in number mode, the hints -> hide-unmatched-rapid-hints option affects typing the hint string (number), but not the filter (letters).

+

Here is couple of invalid links to test the behaviour:

+

one

+

two

+

three

+

four

+

five

+

six

+

seven

+

eight

+

nine

+

ten

+

eleven

+

twelve

+

thirteen

+ + diff --git a/tests/manual/mouse.html b/tests/manual/mouse.html new file mode 100644 index 000000000..3506b5dd5 --- /dev/null +++ b/tests/manual/mouse.html @@ -0,0 +1,16 @@ + + + + + Mouse control + + +
    +
  • Middle- or Ctrl-click on a link should open it in a new tab (fg/bg according to tabs -> background-tabs)
  • +
  • When clicking the link with shift, background-tabs should be reversed accordingly.
  • +
  • Ctrl + Mousewheel should zoom in/out
  • +
  • Back/forward keys on mouse should navigate back/forward
  • +
  • With input -> rocker-gestures set, no context menu should be shown, but pressing left+right/right+left buttons should navigate back/forward
  • +
  • When setting input -> rocker-gestures dynamically, the context menu should be hidden/shown accordingly.
  • + + diff --git a/tests/unit/browser/test_tab.py b/tests/unit/browser/test_tab.py index 0bc240b45..286e00c05 100644 --- a/tests/unit/browser/test_tab.py +++ b/tests/unit/browser/test_tab.py @@ -19,7 +19,7 @@ import pytest -from PyQt5.QtCore import PYQT_VERSION, pyqtSignal, QPoint +from PyQt5.QtCore import PYQT_VERSION from qutebrowser.browser import browsertab from qutebrowser.keyinput import modeman @@ -29,22 +29,16 @@ pytestmark = pytest.mark.usefixtures('redirect_xdg_data') try: from PyQt5.QtWebKitWidgets import QWebView - - class WebView(QWebView): - mouse_wheel_zoom = pyqtSignal(QPoint) except ImportError: - WebView = None + QWebView = None try: from PyQt5.QtWebEngineWidgets import QWebEngineView - - class WebEngineView(QWebEngineView): - mouse_wheel_zoom = pyqtSignal(QPoint) except ImportError: - WebEngineView = None + QWebEngineView = None -@pytest.fixture(params=[WebView, WebEngineView]) +@pytest.fixture(params=[QWebView, QWebEngineView]) def view(qtbot, config_stub, request): config_stub.data = { 'input': { @@ -91,26 +85,35 @@ def tab(request, default_config, qtbot, tab_registry, cookiejar_and_cache): objreg.delete('mode-manager', scope='window', window=0) +class Tab(browsertab.AbstractTab): + + # pylint: disable=abstract-method + + def __init__(self, win_id, mode_manager, parent=None): + super().__init__(win_id, parent) + self.history = browsertab.AbstractHistory(self) + self.scroller = browsertab.AbstractScroller(self, parent=self) + self.caret = browsertab.AbstractCaret(win_id=self.win_id, + mode_manager=mode_manager, + tab=self, parent=self) + self.zoom = browsertab.AbstractZoom(win_id=self.win_id) + self.search = browsertab.AbstractSearch(parent=self) + self.printing = browsertab.AbstractPrinting() + self.elements = browsertab.AbstractElements(self) + + def _install_event_filter(self): + pass + + @pytest.mark.skipif(PYQT_VERSION < 0x050600, reason='Causes segfaults, see #1638') -def test_tab(qtbot, view, config_stub, tab_registry): - tab_w = browsertab.AbstractTab(win_id=0) +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 - mode_manager = modeman.ModeManager(0) - - tab_w.history = browsertab.AbstractHistory(tab_w) - tab_w.scroller = browsertab.AbstractScroller(tab_w, parent=tab_w) - tab_w.caret = browsertab.AbstractCaret(win_id=tab_w.win_id, - mode_manager=mode_manager, - tab=tab_w, parent=tab_w) - tab_w.zoom = browsertab.AbstractZoom(win_id=tab_w.win_id) - tab_w.search = browsertab.AbstractSearch(parent=tab_w) - tab_w.printing = browsertab.AbstractPrinting() - tab_w._set_widget(view) assert tab_w._widget is view assert tab_w.history._tab is tab_w @@ -119,27 +122,3 @@ def test_tab(qtbot, view, config_stub, tab_registry): tab_w.show() qtbot.waitForWindowShown(tab_w) - - -class TestJs: - - @pytest.mark.parametrize('inp, expected', [('1+1', 2), - ('undefined', None)]) - def test_blocking(self, tab, inp, expected): - assert tab.run_js_blocking(inp) == expected - - -class TestTabData: - - def test_known_attr(self): - data = browsertab.TabData() - assert not data.keep_icon - data.keep_icon = True - assert data.keep_icon - - def test_unknown_attr(self): - data = browsertab.TabData() - with pytest.raises(AttributeError): - data.bar = 42 # pylint: disable=assigning-non-slot - with pytest.raises(AttributeError): - data.bar # pylint: disable=pointless-statement diff --git a/tests/unit/browser/webkit/test_history.py b/tests/unit/browser/webkit/test_history.py index 3da69e9f0..7a8cd7387 100644 --- a/tests/unit/browser/webkit/test_history.py +++ b/tests/unit/browser/webkit/test_history.py @@ -28,7 +28,8 @@ from hypothesis import strategies from PyQt5.QtCore import QUrl from PyQt5.QtWebKit import QWebHistoryInterface -from qutebrowser.browser.webkit import history +from qutebrowser.browser import history +from qutebrowser.browser.webkit import webkithistory from qutebrowser.utils import objreg @@ -371,7 +372,7 @@ def hist_interface(): title='example') history_dict = {'http://www.example.com/': entry} fake_hist = FakeWebHistory(history_dict) - interface = history.WebHistoryInterface(fake_hist) + interface = webkithistory.WebHistoryInterface(fake_hist) QWebHistoryInterface.setDefaultInterface(interface) yield QWebHistoryInterface.setDefaultInterface(None) @@ -385,11 +386,23 @@ def test_history_interface(qtbot, webview, hist_interface): webview.load(url) -def test_init(qapp, tmpdir, monkeypatch, fake_save_manager): +@pytest.mark.parametrize('backend', ['webengine', 'webkit']) +def test_init(backend, qapp, tmpdir, monkeypatch, fake_save_manager, + fake_args): + fake_args.backend = backend monkeypatch.setattr(history.standarddir, 'data', lambda: str(tmpdir)) history.init(qapp) hist = objreg.get('web-history') assert hist.parent() is qapp - assert QWebHistoryInterface.defaultInterface()._history is hist + default_interface = QWebHistoryInterface.defaultInterface() + + if backend == 'webkit': + assert default_interface._history is hist + else: + assert backend == 'webengine' + # For this to work, nothing can ever have called setDefaultInterface + # before (so we need to test webengine before webkit) + assert default_interface is None + assert fake_save_manager.add_saveable.called objreg.delete('web-history') diff --git a/tests/unit/browser/webkit/test_qt_javascript.py b/tests/unit/browser/webkit/test_qt_javascript.py index 5dc70bf0d..5b69b2331 100644 --- a/tests/unit/browser/webkit/test_qt_javascript.py +++ b/tests/unit/browser/webkit/test_qt_javascript.py @@ -22,31 +22,9 @@ import pytest -from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtWebKit import QWebSettings -class WebEngineJSChecker(QObject): - - """Check if a JS value provided by a callback is the expected one.""" - - got_result = pyqtSignal(object) - - def __init__(self, qtbot, parent=None): - super().__init__(parent) - self._qtbot = qtbot - - def callback(self, result): - """Callback which can be passed to runJavaScript.""" - self.got_result.emit(result) - - def check(self, expected): - """Wait until the JS result arrived and compare it.""" - with self._qtbot.waitSignal(self.got_result) as blocker: - pass - assert blocker.args == [expected] - - @pytest.mark.parametrize('js_enabled, expected', [(True, 2.0), (False, None)]) def test_simple_js_webkit(webview, js_enabled, expected): """With QtWebKit, evaluateJavaScript works when JS is on.""" @@ -66,7 +44,8 @@ def test_element_js_webkit(webview, js_enabled, expected): @pytest.mark.usefixtures('redirect_xdg_data') @pytest.mark.parametrize('js_enabled, expected', [(True, 2.0), (False, 2.0)]) -def test_simple_js_webengine(qtbot, webengineview, js_enabled, expected): +def test_simple_js_webengine(callback_checker, webengineview, js_enabled, + expected): """With QtWebEngine, runJavaScript works even when JS is off.""" # pylint: disable=no-name-in-module,useless-suppression # If we get there (because of the webengineview fixture) we can be certain @@ -75,6 +54,5 @@ def test_simple_js_webengine(qtbot, webengineview, js_enabled, expected): webengineview.settings().setAttribute(QWebEngineSettings.JavascriptEnabled, js_enabled) - checker = WebEngineJSChecker(qtbot) - webengineview.page().runJavaScript('1 + 1', checker.callback) - checker.check(expected) + webengineview.page().runJavaScript('1 + 1', callback_checker.callback) + callback_checker.check(expected) diff --git a/tests/unit/browser/webkit/test_webkitelem.py b/tests/unit/browser/webkit/test_webkitelem.py index ea613699e..efe676c5a 100644 --- a/tests/unit/browser/webkit/test_webkitelem.py +++ b/tests/unit/browser/webkit/test_webkitelem.py @@ -67,11 +67,13 @@ def get_webelem(geometry=None, frame=None, *, null=False, style=None, else: scroll_x = frame.scrollPosition().x() scroll_y = frame.scrollPosition().y() + if js_rect_return is None: if frame is None or zoom_text_only: zoom = 1.0 else: zoom = frame.zoomFactor() + elem.evaluateJavaScript.return_value = { "length": 1, "0": { @@ -118,7 +120,7 @@ def get_webelem(geometry=None, frame=None, *, null=False, style=None, return style_dict[name] elem.styleProperty.side_effect = _style_property - wrapped = webkitelem.WebKitElement(elem) + wrapped = webkitelem.WebKitElement(elem, tab=None) return wrapped @@ -216,11 +218,12 @@ class TestSelectorsAndFilters: # Make sure setting HTML succeeded and there's a new element assert len(webframe.findAllElements('*')) == 3 elems = webframe.findAllElements(webelem.SELECTORS[group]) - elems = [webkitelem.WebKitElement(e) for e in elems] + elems = [webkitelem.WebKitElement(e, tab=None) for e in elems] filterfunc = webelem.FILTERS.get(group, lambda e: True) elems = [e for e in elems if filterfunc(e)] assert bool(elems) == matching + class TestWebKitElement: """Generic tests for WebKitElement. @@ -241,7 +244,7 @@ class TestWebKitElement: def test_double_wrap(self, elem): """Test wrapping a WebKitElement.""" with pytest.raises(TypeError) as excinfo: - webkitelem.WebKitElement(elem) + webkitelem.WebKitElement(elem, tab=None) assert str(excinfo.value) == "Trying to wrap a wrapper!" @pytest.mark.parametrize('code', [ @@ -252,17 +255,11 @@ class TestWebKitElement: lambda e: None in e, list, # __iter__ len, - lambda e: e.frame(), + lambda e: e.has_frame(), lambda e: e.geometry(), - lambda e: e.document_element(), - lambda e: e.create_inside('span'), - lambda e: e.find_first('span'), lambda e: e.style_property('visibility', strategy='computed'), lambda e: e.text(), lambda e: e.set_text('foo'), - lambda e: e.set_inner_xml(''), - lambda e: e.remove_from_document(), - lambda e: e.set_style_property('visibility', 'hidden'), lambda e: e.is_writable(), lambda e: e.is_content_editable(), lambda e: e.is_editable(), @@ -275,9 +272,7 @@ class TestWebKitElement: lambda e: e.rect_on_view(), lambda e: e.is_visible(None), ], ids=['str', 'getitem', 'setitem', 'delitem', 'contains', 'iter', 'len', - 'frame', 'geometry', 'document_element', 'create_inside', - 'find_first', 'style_property', 'text', 'set_text', - 'set_inner_xml', 'remove_from_document', 'set_style_property', + 'frame', 'geometry', 'style_property', 'text', 'set_text', 'is_writable', 'is_content_editable', 'is_editable', 'is_text_input', 'remove_blank_target', 'debug_text', 'outer_xml', 'tag_name', 'run_js_async', 'rect_on_view', 'is_visible']) @@ -334,7 +329,7 @@ class TestWebKitElement: def test_eq(self): one = get_webelem() - two = webkitelem.WebKitElement(one._elem) + two = webkitelem.WebKitElement(one._elem, tab=None) assert one == two def test_eq_other_type(self): @@ -399,7 +394,6 @@ class TestWebKitElement: assert elem.debug_text() == expected @pytest.mark.parametrize('attribute, code', [ - ('webFrame', lambda e: e.frame()), ('geometry', lambda e: e.geometry()), ('toOuterXml', lambda e: e.outer_xml()), ]) @@ -409,16 +403,11 @@ class TestWebKitElement: setattr(mock, 'return_value', sentinel) assert code(elem) is sentinel - @pytest.mark.parametrize('code, method, args', [ - (lambda e: e.set_inner_xml('foo'), 'setInnerXml', ['foo']), - (lambda e: e.set_style_property('foo', 'bar'), 'setStyleProperty', - ['foo', 'bar']), - (lambda e: e.remove_from_document(), 'removeFromDocument', []), - ]) - def test_simple_setters(self, elem, code, method, args): - code(elem) - mock = getattr(elem._elem, method) - mock.assert_called_with(*args) + @pytest.mark.parametrize('frame, expected', [ + (object(), True), (None, False)]) + def test_has_frame(self, elem, frame, expected): + elem._elem.webFrame.return_value = frame + assert elem.has_frame() == expected def test_tag_name(self, elem): elem._elem.tagName.return_value = 'SPAN' @@ -427,34 +416,6 @@ class TestWebKitElement: def test_style_property(self, elem): assert elem.style_property('foo', strategy='computed') == 'bar' - def test_document_element(self, stubs): - doc_elem = get_webelem() - frame = stubs.FakeWebFrame(document_element=doc_elem._elem) - elem = get_webelem(frame=frame) - - doc_elem_ret = elem.document_element() - assert isinstance(doc_elem_ret, webkitelem.WebKitElement) - assert doc_elem_ret == doc_elem - - def test_find_first(self, elem): - result = get_webelem() - elem._elem.findFirst.return_value = result._elem - find_result = elem.find_first('') - assert isinstance(find_result, webkitelem.WebKitElement) - assert find_result == result - - def test_create_inside(self, elem): - child = get_webelem() - elem._elem.lastChild.return_value = child._elem - assert elem.create_inside('span')._elem is child._elem - elem._elem.appendInside.assert_called_with('') - - def test_find_first_null(self, elem): - nullelem = get_webelem() - nullelem._elem.isNull.return_value = True - elem._elem.findFirst.return_value = nullelem._elem - assert elem.find_first('foo') is None - @pytest.mark.parametrize('use_js, editable, expected', [ (True, 'false', 'js'), (True, 'true', 'nojs'), @@ -801,20 +762,14 @@ class TestRectOnView: @pytest.mark.parametrize('js_rect', [None, {}]) @pytest.mark.parametrize('zoom_text_only', [True, False]) - @pytest.mark.parametrize('adjust_zoom', [True, False]) - def test_zoomed(self, stubs, config_stub, js_rect, zoom_text_only, - adjust_zoom): + def test_zoomed(self, stubs, config_stub, js_rect, zoom_text_only): """Make sure the coordinates are adjusted when zoomed.""" config_stub.data = {'ui': {'zoom-text-only': zoom_text_only}} geometry = QRect(10, 10, 4, 4) frame = stubs.FakeWebFrame(QRect(0, 0, 100, 100), zoom=0.5) elem = get_webelem(geometry, frame, js_rect_return=js_rect, zoom_text_only=zoom_text_only) - rect = elem.rect_on_view(adjust_zoom=adjust_zoom) - if zoom_text_only or (js_rect is None and adjust_zoom): - assert rect == QRect(10, 10, 4, 4) - else: - assert rect == QRect(20, 20, 8, 8) + assert elem.rect_on_view() == QRect(10, 10, 4, 4) class TestGetChildFrames: diff --git a/tests/unit/commands/test_cmdutils.py b/tests/unit/commands/test_cmdutils.py index 1ecdbaf3a..1bef17d86 100644 --- a/tests/unit/commands/test_cmdutils.py +++ b/tests/unit/commands/test_cmdutils.py @@ -21,6 +21,10 @@ """Tests for qutebrowser.commands.cmdutils.""" +import sys +import logging +import types + import pytest from qutebrowser.commands import cmdutils, cmdexc, argparser, command @@ -354,6 +358,25 @@ class TestArgument: assert str(excinfo.value) == "Argument marked as both count/win_id!" + def test_no_docstring(self, caplog): + with caplog.at_level(logging.WARNING): + @cmdutils.register() + def fun(): + # no docstring + pass + assert len(caplog.records) == 1 + msg = caplog.records[0].message + assert msg.endswith('test_cmdutils.py has no docstring') + + def test_no_docstring_with_optimize(self, monkeypatch): + """With -OO we'd get a warning on start, but no warning afterwards.""" + monkeypatch.setattr(sys, 'flags', types.SimpleNamespace(optimize=2)) + + @cmdutils.register() + def fun(): + # no docstring + pass + class TestRun: diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index 93adf8a0c..b29f252e1 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -96,30 +96,30 @@ def cmdutils_patch(monkeypatch, stubs): @cmdutils.argument('option', completion=usertypes.Completion.option) @cmdutils.argument('value', completion=usertypes.Completion.value) def set_command(section_=None, option=None, value=None): - """docstring!""" + """docstring.""" pass @cmdutils.argument('topic', completion=usertypes.Completion.helptopic) def show_help(tab=False, bg=False, window=False, topic=None): - """docstring!""" + """docstring.""" pass @cmdutils.argument('url', completion=usertypes.Completion.url) @cmdutils.argument('count', count=True) def openurl(url=None, implicit=False, bg=False, tab=False, window=False, count=None): - """docstring!""" + """docstring.""" pass @cmdutils.argument('win_id', win_id=True) @cmdutils.argument('command', completion=usertypes.Completion.command) def bind(key, win_id, command=None, *, mode='normal', force=False): - """docstring!""" + """docstring.""" # pylint: disable=unused-variable pass def tab_detach(): - """docstring!""" + """docstring.""" pass cmds = { diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index c9739661e..a5faa0df1 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -96,32 +96,56 @@ def test_maybe_resize_completion(completionview, config_stub, qtbot): completionview.maybe_resize_completion() -@pytest.mark.parametrize('tree, count, expected', [ - ([['Aa']], 1, 'Aa'), - ([['Aa']], -1, 'Aa'), - ([['Aa'], ['Ba']], 1, 'Aa'), - ([['Aa'], ['Ba']], -1, 'Ba'), - ([['Aa'], ['Ba']], 2, 'Ba'), - ([['Aa'], ['Ba']], -2, 'Aa'), - ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ac'), - ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ba'), - ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 6, 'Ca'), - ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 7, 'Aa'), - ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -1, 'Ca'), - ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -2, 'Bb'), - ([['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], -4, 'Ac'), - ([[], ['Ba', 'Bb']], 1, 'Ba'), - ([[], ['Ba', 'Bb']], -1, 'Bb'), - ([[], [], ['Ca', 'Cb']], 1, 'Ca'), - ([[], [], ['Ca', 'Cb']], -1, 'Cb'), - ([['Aa'], []], 1, 'Aa'), - ([['Aa'], []], -1, 'Aa'), - ([['Aa'], [], []], 1, 'Aa'), - ([['Aa'], [], []], -1, 'Aa'), - ([[]], 1, None), - ([[]], -1, None), +@pytest.mark.parametrize('which, tree, count, expected', [ + ('next', [['Aa']], 1, 'Aa'), + ('prev', [['Aa']], 1, 'Aa'), + ('next', [['Aa'], ['Ba']], 1, 'Aa'), + ('prev', [['Aa'], ['Ba']], 1, 'Ba'), + ('next', [['Aa'], ['Ba']], 2, 'Ba'), + ('prev', [['Aa'], ['Ba']], 2, 'Aa'), + ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ac'), + ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ba'), + ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 6, 'Ca'), + ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 7, 'Aa'), + ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 1, 'Ca'), + ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Bb'), + ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 4, 'Ac'), + ('next', [[], ['Ba', 'Bb']], 1, 'Ba'), + ('prev', [[], ['Ba', 'Bb']], 1, 'Bb'), + ('next', [[], [], ['Ca', 'Cb']], 1, 'Ca'), + ('prev', [[], [], ['Ca', 'Cb']], 1, 'Cb'), + ('next', [['Aa'], []], 1, 'Aa'), + ('prev', [['Aa'], []], 1, 'Aa'), + ('next', [['Aa'], [], []], 1, 'Aa'), + ('prev', [['Aa'], [], []], 1, 'Aa'), + ('next', [['Aa'], [], ['Ca', 'Cb']], 2, 'Ca'), + ('prev', [['Aa'], [], ['Ca', 'Cb']], 1, 'Cb'), + ('next', [[]], 1, None), + ('prev', [[]], 1, None), + ('next-category', [['Aa']], 1, 'Aa'), + ('prev-category', [['Aa']], 1, 'Aa'), + ('next-category', [['Aa'], ['Ba']], 1, 'Aa'), + ('prev-category', [['Aa'], ['Ba']], 1, 'Ba'), + ('next-category', [['Aa'], ['Ba']], 2, 'Ba'), + ('prev-category', [['Aa'], ['Ba']], 2, 'Aa'), + ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'), + ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 2, 'Ba'), + ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Ca'), + ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], 3, 'Aa'), + ('next-category', [[], ['Ba', 'Bb']], 1, 'Ba'), + ('prev-category', [[], ['Ba', 'Bb']], 1, 'Ba'), + ('next-category', [[], [], ['Ca', 'Cb']], 1, 'Ca'), + ('prev-category', [[], [], ['Ca', 'Cb']], 1, 'Ca'), + ('next-category', [[], [], ['Ca', 'Cb']], 2, 'Ca'), + ('prev-category', [[], [], ['Ca', 'Cb']], 2, 'Ca'), + ('next-category', [['Aa'], [], []], 1, 'Aa'), + ('prev-category', [['Aa'], [], []], 1, 'Aa'), + ('next-category', [['Aa'], [], ['Ca', 'Cb']], 2, 'Ca'), + ('prev-category', [['Aa'], [], ['Ca', 'Cb']], 1, 'Ca'), + ('next-category', [[]], 1, None), + ('prev-category', [[]], 1, None), ]) -def test_completion_item_focus(tree, count, expected, completionview): +def test_completion_item_focus(which, tree, count, expected, completionview): """Test that on_next_prev_item moves the selection properly. Args: @@ -139,8 +163,7 @@ def test_completion_item_focus(tree, count, expected, completionview): filtermodel = sortfilter.CompletionFilterModel(model, parent=completionview) completionview.set_model(filtermodel) - direction = 'prev' if count < 0 else 'next' - for _ in range(abs(count)): - completionview.completion_item_focus(direction) + for _ in range(count): + completionview.completion_item_focus(which) idx = completionview.selectionModel().currentIndex() assert filtermodel.data(idx) == expected diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 6772e1201..83f8060e1 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -27,21 +27,22 @@ from PyQt5.QtCore import QUrl from PyQt5.QtWidgets import QTreeView from qutebrowser.completion.models import miscmodels, urlmodel, configmodel -from qutebrowser.browser.webkit import history +from qutebrowser.browser import history from qutebrowser.config import sections, value -def _get_completions(model): - """Collect all the completion entries of a model, organized by category. +def _check_completions(model, expected): + """Check that a model contains the expected items in any order. - The result is a list of form: - [ - (CategoryName: [(name, desc, misc), ...]), - (CategoryName: [(name, desc, misc), ...]), - ... - ] + Args: + expected: A dict of form + { + CategoryName: [(name, desc, misc), ...], + CategoryName: [(name, desc, misc), ...], + ... + } """ - completions = [] + actual = {} for i in range(0, model.rowCount()): category = model.item(i) entries = [] @@ -50,8 +51,12 @@ def _get_completions(model): desc = category.child(j, 1) misc = category.child(j, 2) entries.append((name.text(), desc.text(), misc.text())) - completions.append((category.text(), entries)) - return completions + actual[category.text()] = entries + for cat_name, expected_entries in expected.items(): + assert cat_name in actual + actual_items = actual[cat_name] + for expected_item in expected_entries: + assert expected_item in actual_items def _patch_cmdutils(monkeypatch, stubs, symbol): @@ -165,7 +170,6 @@ def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub, Validates that: - only non-hidden and non-deprecated commands are included - - commands are sorted by name - the command description is shown in the desc column - the binding (if any) is shown in the misc column - aliases are included @@ -173,55 +177,56 @@ def test_command_completion(qtmodeltester, monkeypatch, stubs, config_stub, _patch_cmdutils(monkeypatch, stubs, 'qutebrowser.completion.models.miscmodels.cmdutils') config_stub.data['aliases'] = {'rock': 'roll'} - key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll'}) + key_config_stub.set_bindings_for('normal', {'s': 'stop', + 'rr': 'roll', + 'ro': 'rock'}) model = miscmodels.CommandCompletionModel() qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Commands", [ + _check_completions(model, { + "Commands": [ + ('stop', 'stop qutebrowser', 's'), ('drop', 'drop all user data', ''), - ('rock', "Alias for 'roll'", ''), ('roll', 'never gonna give you up', 'rr'), - ('stop', 'stop qutebrowser', 's') - ]) - ] + ('rock', "Alias for 'roll'", 'ro'), + ] + }) -def test_help_completion(qtmodeltester, monkeypatch, stubs): +def test_help_completion(qtmodeltester, monkeypatch, stubs, key_config_stub): """Test the results of command completion. Validates that: - - only non-hidden and non-deprecated commands are included - - commands are sorted by name + - only non-deprecated commands are included - the command description is shown in the desc column - the binding (if any) is shown in the misc column - aliases are included - only the first line of a multiline description is shown """ module = 'qutebrowser.completion.models.miscmodels' + key_config_stub.set_bindings_for('normal', {'s': 'stop', 'rr': 'roll'}) _patch_cmdutils(monkeypatch, stubs, module + '.cmdutils') _patch_configdata(monkeypatch, stubs, module + '.configdata.DATA') model = miscmodels.HelpCompletionModel() qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Commands", [ + _check_completions(model, { + "Commands": [ + (':stop', 'stop qutebrowser', 's'), (':drop', 'drop all user data', ''), - (':roll', 'never gonna give you up', ''), - (':stop', 'stop qutebrowser', '') - ]), - ("Settings", [ + (':roll', 'never gonna give you up', 'rr'), + (':hide', '', ''), + ], + "Settings": [ ('general->time', 'Is an illusion.', ''), ('general->volume', 'Goes to 11', ''), ('ui->gesture', 'Waggle your hands to control qutebrowser', ''), ('ui->mind', 'Enable mind-control ui (experimental)', ''), ('ui->voice', 'Whether to respond to voice commands', ''), - ]) - ] + ] + }) def test_quickmark_completion(qtmodeltester, quickmarks): @@ -230,14 +235,13 @@ def test_quickmark_completion(qtmodeltester, quickmarks): qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Quickmarks", [ + _check_completions(model, { + "Quickmarks": [ ('aw', 'https://wiki.archlinux.org', ''), ('ddg', 'https://duckduckgo.com', ''), ('wiki', 'https://wikipedia.org', ''), - ]) - ] + ] + }) def test_bookmark_completion(qtmodeltester, bookmarks): @@ -246,14 +250,13 @@ def test_bookmark_completion(qtmodeltester, bookmarks): qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Bookmarks", [ + _check_completions(model, { + "Bookmarks": [ ('https://github.com', 'GitHub', ''), ('https://python.org', 'Welcome to Python.org', ''), ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), - ]) - ] + ] + }) def test_url_completion(qtmodeltester, config_stub, web_history, quickmarks, @@ -271,23 +274,22 @@ def test_url_completion(qtmodeltester, config_stub, web_history, quickmarks, qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Quickmarks", [ + _check_completions(model, { + "Quickmarks": [ ('https://wiki.archlinux.org', 'aw', ''), ('https://duckduckgo.com', 'ddg', ''), ('https://wikipedia.org', 'wiki', ''), - ]), - ("Bookmarks", [ + ], + "Bookmarks": [ ('https://github.com', 'GitHub', ''), ('https://python.org', 'Welcome to Python.org', ''), ('http://qutebrowser.org', 'qutebrowser | qutebrowser', ''), - ]), - ("History", [ + ], + "History": [ ('https://python.org', 'Welcome to Python.org', '2016-03-08'), ('https://github.com', 'GitHub', '2016-05-01'), - ]), - ] + ], + }) def test_url_completion_delete_bookmark(qtmodeltester, config_stub, @@ -332,10 +334,9 @@ def test_session_completion(qtmodeltester, session_manager_stub): qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Sessions", [('default', '', ''), ('1', '', ''), ('2', '', '')]) - ] + _check_completions(model, { + "Sessions": [('default', '', ''), ('1', '', ''), ('2', '', '')] + }) def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, @@ -352,17 +353,16 @@ def test_tab_completion(qtmodeltester, fake_web_tab, app_stub, win_registry, qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ('0', [ + _check_completions(model, { + '0': [ ('0/1', 'https://github.com', 'GitHub'), ('0/2', 'https://wikipedia.org', 'Wikipedia'), ('0/3', 'https://duckduckgo.com', 'DuckDuckGo') - ]), - ('1', [ + ], + '1': [ ('1/1', 'https://wiki.archlinux.org', 'ArchWiki'), - ]) - ] + ] + }) def test_tab_completion_delete(qtmodeltester, fake_web_tab, qtbot, app_stub, @@ -397,13 +397,12 @@ def test_setting_section_completion(qtmodeltester, monkeypatch, stubs): qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Sections", [ + _check_completions(model, { + "Sections": [ ('general', 'General/miscellaneous options.', ''), ('ui', 'General options related to the user interface.', ''), - ]) - ] + ] + }) def test_setting_option_completion(qtmodeltester, monkeypatch, stubs, @@ -417,14 +416,13 @@ def test_setting_option_completion(qtmodeltester, monkeypatch, stubs, qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("ui", [ + _check_completions(model, { + "ui": [ ('gesture', 'Waggle your hands to control qutebrowser', 'off'), ('mind', 'Enable mind-control ui (experimental)', 'on'), ('voice', 'Whether to respond to voice commands', 'sometimes'), - ]) - ] + ] + }) def test_setting_value_completion(qtmodeltester, monkeypatch, stubs, @@ -436,14 +434,43 @@ def test_setting_value_completion(qtmodeltester, monkeypatch, stubs, qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) - actual = _get_completions(model) - assert actual == [ - ("Current/Default", [ + _check_completions(model, { + "Current/Default": [ ('0', 'Current value', ''), ('11', 'Default value', ''), - ]), - ("Completions", [ + ], + "Completions": [ ('0', '', ''), ('11', '', ''), - ]) - ] + ] + }) + + +def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub, + key_config_stub): + """Test the results of keybinding command completion. + + Validates that: + - only non-hidden and non-deprecated commands are included + - the command description is shown in the desc column + - the binding (if any) is shown in the misc column + - aliases are included + """ + _patch_cmdutils(monkeypatch, stubs, + 'qutebrowser.completion.models.miscmodels.cmdutils') + config_stub.data['aliases'] = {'rock': 'roll'} + key_config_stub.set_bindings_for('normal', {'s': 'stop', + 'rr': 'roll', + 'ro': 'rock'}) + model = miscmodels.BindCompletionModel() + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + "Commands": [ + ('stop', 'stop qutebrowser', 's'), + ('drop', 'drop all user data', ''), + ('hide', '', ''), + ('rock', "Alias for 'roll'", 'ro'), + ] + }) diff --git a/tests/unit/completion/test_sortfilter.py b/tests/unit/completion/test_sortfilter.py index c63d08ed5..e3ae69146 100644 --- a/tests/unit/completion/test_sortfilter.py +++ b/tests/unit/completion/test_sortfilter.py @@ -59,6 +59,7 @@ def _extract_model_data(model): data.append(row) return data + @pytest.mark.parametrize('pattern, data, expected', [ ('foo', 'barfoobar', True), ('foo', 'barFOObar', True), diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index f2a6a79f1..f796f8152 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -210,11 +210,67 @@ class TestConfigParser: assert objects.cfg.get('general', 'save-session') +class TestTransformers: + + """Test value transformers in CHANGED_OPTIONS.""" + + @pytest.mark.parametrize('val, expected', [('a', 'b'), ('c', 'c')]) + def test_get_value_transformer(self, val, expected): + func = config._get_value_transformer({'a': 'b'}) + assert func(val) == expected + + @pytest.mark.parametrize('val, expected', [ + ('top', 'top'), + ('north', 'top'), + ('south', 'bottom'), + ('west', 'left'), + ('east', 'right'), + ]) + def test_position(self, val, expected): + func = config._transform_position + assert func(val) == expected + + OLD_GRADIENT = ('-webkit-gradient(linear, left top, left bottom, ' + 'color-stop(0%,{}), color-stop(100%,{}))') + NEW_GRADIENT = ('qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 {}, ' + 'stop:1 {})') + + @pytest.mark.parametrize('val, expected', [ + ('-unknown-stuff', None), + ('blue', 'blue'), + ('rgba(1, 2, 3, 4)', 'rgba(1, 2, 3, 4)'), + ('-webkit-gradient(unknown)', None), + (OLD_GRADIENT.format('blah', 'blah'), None), + (OLD_GRADIENT.format('red', 'green'), + NEW_GRADIENT.format('rgba(255, 0, 0, 0.8)', 'rgba(0, 128, 0, 0.8)')), + (OLD_GRADIENT.format(' red', ' green'), + NEW_GRADIENT.format('rgba(255, 0, 0, 0.8)', 'rgba(0, 128, 0, 0.8)')), + (OLD_GRADIENT.format('#101010', ' #202020'), + NEW_GRADIENT.format('rgba(16, 16, 16, 0.8)', + 'rgba(32, 32, 32, 0.8)')), + (OLD_GRADIENT.format('#666', ' #777'), + NEW_GRADIENT.format('rgba(102, 102, 102, 0.8)', + 'rgba(119, 119, 119, 0.8)')), + (OLD_GRADIENT.format('red', 'green') + 'more stuff', None), + ]) + def test_hint_color(self, val, expected): + assert config._transform_hint_color(val) == expected + + @pytest.mark.parametrize('val, expected', [ + ('bold 12pt Monospace', 'bold 12pt ${_monospace}'), + ('23pt Monospace', '23pt ${_monospace}'), + ('bold 12pt ${_monospace}', 'bold 12pt ${_monospace}'), + ('bold 12pt Comic Sans MS', 'bold 12pt Comic Sans MS'), + ]) + def test_hint_font(self, val, expected): + assert config._transform_hint_font(val) == expected + + class TestKeyConfigParser: """Test config.parsers.keyconf.KeyConfigParser.""" - def test_cmd_binding(self, cmdline_test): + def test_cmd_binding(self, cmdline_test, config_stub): """Test various command bindings. See https://github.com/The-Compiler/qutebrowser/issues/615 @@ -222,6 +278,7 @@ class TestKeyConfigParser: Args: cmdline_test: A pytest fixture which provides testcases. """ + config_stub.data = {'aliases': []} kcp = keyconf.KeyConfigParser(None, None) kcp._cur_section = 'normal' if cmdline_test.valid: @@ -277,13 +334,13 @@ class TestKeyConfigParser: ('search ;; clear-keychain', 'clear-keychain ;; search'), ('search;;clear-keychain', 'clear-keychain ;; search'), ('search;;foo', None), - ('leave-mode', 'clear-keychain ;; leave-mode'), + ('clear-keychain ;; leave-mode', 'leave-mode'), ('leave-mode ;; foo', None), ('download-remove --all', 'download-clear'), ('hint links fill ":open {hint-url}"', - 'hint links fill :open {hint-url}'), + 'hint links fill :open {hint-url}'), ('hint links fill ":open -t {hint-url}"', 'hint links fill :open -t {hint-url}'), @@ -297,6 +354,19 @@ class TestKeyConfigParser: ('yank -ds', 'yank domain -s'), ('yank -p', 'yank pretty-url'), ('yank -ps', 'yank pretty-url -s'), + + ('paste', 'open -- {clipboard}'), + ('paste -s', 'open -- {primary}'), + ('paste -t', 'open -t -- {clipboard}'), + ('paste -ws', 'open -w -- {primary}'), + + ('open {clipboard}', 'open -- {clipboard}'), + ('open -t {clipboard}', 'open -t -- {clipboard}'), + ('open -b {primary}', 'open -b -- {primary}'), + + ('set-cmd-text -s :search', 'set-cmd-text /'), + ('set-cmd-text -s :search -r', 'set-cmd-text ?'), + ('set-cmd-text -s :', 'set-cmd-text :'), ] ) def test_migrations(self, old, new_expected): diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 391ae757a..3ad579266 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -871,7 +871,7 @@ class ColorTests: ('hsva(359, 255, 255, 255)', [configtypes.QssColor]), ('hsv(10%, 10%, 10%)', [configtypes.QssColor]), ('hsv(10%,10%,10%)', [configtypes.QssColor]), - ('qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 white, ' + ('qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 white, ' 'stop: 0.4 gray, stop:1 green)', [configtypes.QssColor]), ('qconicalgradient(cx:0.5, cy:0.5, angle:30, stop:0 white, ' 'stop:1 #00FF00)', [configtypes.QssColor]), diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 40ab6184f..196d983d5 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -183,7 +183,17 @@ class TestSpecialKeys: keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) keyparser.execute.assert_called_once_with( - 'ctrla', keyparser.Type.special) + 'ctrla', keyparser.Type.special, None) + + def test_valid_key_count(self, fake_keyevent_factory, keyparser): + if sys.platform == 'darwin': + modifier = Qt.MetaModifier + else: + modifier = Qt.ControlModifier + keyparser.handle(fake_keyevent_factory(5, text='5')) + keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier, text='A')) + keyparser.execute.assert_called_once_with( + 'ctrla', keyparser.Type.special, 5) def test_invalid_key(self, fake_keyevent_factory, keyparser): keyparser.handle(fake_keyevent_factory( @@ -217,7 +227,7 @@ class TestKeyChain: keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) keyparser.execute.assert_called_once_with( - 'ctrla', keyparser.Type.special) + 'ctrla', keyparser.Type.special, None) assert keyparser._keystring == '' def test_invalid_special_key(self, fake_keyevent_factory, keyparser): diff --git a/tests/unit/utils/test_debug.py b/tests/unit/utils/test_debug.py index 035bd3e08..7b00502f7 100644 --- a/tests/unit/utils/test_debug.py +++ b/tests/unit/utils/test_debug.py @@ -39,7 +39,7 @@ class EventObject(QObject): def test_log_events(qapp, caplog): obj = EventObject() - qapp.postEvent(obj, QEvent(QEvent.User)) + qapp.sendEvent(obj, QEvent(QEvent.User)) qapp.processEvents() assert len(caplog.records) == 1 assert caplog.records[0].msg == 'Event in test_debug.EventObject: User' diff --git a/tests/unit/utils/test_javascript.py b/tests/unit/utils/test_javascript.py index d11ab0074..298a75312 100644 --- a/tests/unit/utils/test_javascript.py +++ b/tests/unit/utils/test_javascript.py @@ -127,8 +127,10 @@ class TestStringEscape: ('foo\\bar', r'"foo\\bar"'), (42, '42'), (23.42, '23.42'), + (False, 'false'), (None, 'undefined'), (object(), TypeError), + (True, 'true'), ]) def test_convert_js_arg(arg, expected): if expected is TypeError: @@ -138,8 +140,10 @@ def test_convert_js_arg(arg, expected): assert javascript._convert_js_arg(arg) == expected -def test_assemble(monkeypatch): - monkeypatch.setattr(javascript.utils, 'read_file', - ''.format) - expected = '\n_qutebrowser_func(23);' - assert javascript.assemble('foo', 'func', 23) == expected +@pytest.mark.parametrize('base, expected_base', [ + ('window', 'window'), + ('foo', 'window._qutebrowser.foo'), +]) +def test_assemble(base, expected_base): + expected = '"use strict";\n{}.func(23);'.format(expected_base) + assert javascript.assemble(base, 'func', 23) == expected diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py index 4bf12c526..c386b9694 100644 --- a/tests/unit/utils/test_log.py +++ b/tests/unit/utils/test_log.py @@ -29,6 +29,7 @@ import pytest import pytest_catchlog from qutebrowser.utils import log +from qutebrowser.misc import utilcmds @pytest.yield_fixture(autouse=True) @@ -167,6 +168,19 @@ class TestLogFilter: record = self._make_record(logger, "bacon", level=logging.INFO) assert logfilter.filter(record) + @pytest.mark.parametrize('category, logged_before, logged_after', [ + ('init', True, False), ('url', False, True), ('js', False, True)]) + def test_debug_log_filter_cmd(self, monkeypatch, logger, category, + logged_before, logged_after): + logfilter = log.LogFilter(["init"]) + monkeypatch.setattr(log, 'console_filter', logfilter) + + record = self._make_record(logger, category) + + assert logfilter.filter(record) == logged_before + utilcmds.debug_log_filter('url,js') + assert logfilter.filter(record) == logged_after + class TestRAMHandler: diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index af0e62ed3..e036c4b9e 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -620,6 +620,33 @@ class TestIncDecNumber: base_url, incdec, segments={'host', 'path', 'query', 'anchor'}) assert new_url == expected_url + @pytest.mark.parametrize('incdec', ['increment', 'decrement']) + @pytest.mark.parametrize('value', [ + '{}foo', 'foo{}', 'foo{}bar', '42foo{}' + ]) + @pytest.mark.parametrize('url', [ + 'http://example.com:80/v1/path/{}/test', + 'http://example.com:80/v1/query_test?value={}', + 'http://example.com:80/v1/anchor_test#{}', + 'http://host_{}_test.com:80', + 'http://m4ny.c0m:80/number5/3very?where=yes#{}' + ]) + @pytest.mark.parametrize('count', [1, 5, 100]) + def test_incdec_number_count(self, incdec, value, url, count): + """Test incdec_number with valid URLs and a count.""" + base_value = value.format(20) + if incdec == 'increment': + expected_value = value.format(20 + count) + else: + expected_value = value.format(20 - count) + + base_url = QUrl(url.format(base_value)) + expected_url = QUrl(url.format(expected_value)) + new_url = urlutils.incdec_number( + base_url, incdec, count, + segments={'host', 'path', 'query', 'anchor'}) + assert new_url == expected_url + @pytest.mark.parametrize('number, expected, incdec', [ ('01', '02', 'increment'), ('09', '10', 'increment'), diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 60c6ec782..5eabf5488 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -614,16 +614,18 @@ class FakeQSslSocket: return self._version -@pytest.mark.parametrize('git_commit, harfbuzz, frozen, style, equal_qt', [ - (True, True, False, True, True), # normal - (False, True, False, True, True), # no git commit - (True, False, False, True, True), # HARFBUZZ unset - (True, True, True, True, True), # frozen - (True, True, True, False, True), # no style - (True, True, False, True, False), # different Qt +@pytest.mark.parametrize(['git_commit', 'harfbuzz', 'frozen', 'style', + 'equal_qt', 'with_webkit'], [ + (True, True, False, True, True, True), # normal + (False, True, False, True, True, True), # no git commit + (True, False, False, True, True, True), # HARFBUZZ unset + (True, True, True, True, True, True), # frozen + (True, True, True, False, True, True), # no style + (True, True, False, True, False, True), # different Qt + (True, True, False, True, True, False), # no webkit ]) def test_version_output(git_commit, harfbuzz, frozen, style, equal_qt, - stubs, monkeypatch): + with_webkit, stubs, monkeypatch): """Test version.version().""" import_path = os.path.abspath('/IMPORTPATH') patches = { @@ -638,7 +640,7 @@ def test_version_output(git_commit, harfbuzz, frozen, style, equal_qt, 'QT VERSION' if equal_qt else 'QT RUNTIME VERSION'), '_module_versions': lambda: ['MODULE VERSION 1', 'MODULE VERSION 2'], '_pdfjs_version': lambda: 'PDFJS VERSION', - 'qWebKitVersion': lambda: 'WEBKIT VERSION', + 'qWebKitVersion': (lambda: 'WEBKIT VERSION') if with_webkit else None, 'QSslSocket': FakeQSslSocket('SSL VERSION'), 'platform.platform': lambda: 'PLATFORM', 'platform.architecture': lambda: ('ARCHITECTURE', ''), @@ -672,7 +674,7 @@ def test_version_output(git_commit, harfbuzz, frozen, style, equal_qt, MODULE VERSION 1 MODULE VERSION 2 pdf.js: PDFJS VERSION - Webkit: WEBKIT VERSION + Webkit: {webkit} Harfbuzz: {harfbuzz} SSL: SSL VERSION {style} @@ -692,6 +694,7 @@ def test_version_output(git_commit, harfbuzz, frozen, style, equal_qt, 'harfbuzz': 'HARFBUZZ' if harfbuzz else 'system', 'frozen': str(frozen), 'import_path': import_path, + 'webkit': 'WEBKIT VERSION' if with_webkit else 'no' } expected = template.rstrip('\n').format(**substitutions) diff --git a/tox.ini b/tox.ini index 2e7b005f3..f6a6b88cd 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ [tox] envlist = py34,py35-cov,misc,vulture,flake8,pylint,pyroma,check-manifest distshare = {toxworkdir} +skipsdist = true [testenv] # https://bitbucket.org/hpk42/tox/issue/246/ - only needed for Windows though @@ -61,7 +62,6 @@ deps = {[testenv:mkvenv]deps} # cx_Freeze doesn't support Python 3.5 yet basepython = python3.4 passenv = {[testenv]passenv} -skip_install = true deps = {[testenv]deps} -r{toxinidir}/misc/requirements/requirements-cxfreeze.txt @@ -75,7 +75,7 @@ ignore_errors = true basepython = python3 # For global .gitignore files passenv = HOME -deps = -r{toxinidir}/misc/requirements/requirements-pip.txt +deps = commands = {envpython} scripts/dev/misc_checks.py git {envpython} scripts/dev/misc_checks.py vcs @@ -85,7 +85,9 @@ commands = basepython = python3 deps = -r{toxinidir}/misc/requirements/requirements-pip.txt + -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-vulture.txt +setenv = PYTHONPATH={toxinidir} commands = {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} scripts/dev/run_vulture.py @@ -120,27 +122,28 @@ deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-flake8.txt commands = - {envpython} -m flake8 + {envpython} -m flake8 {posargs:qutebrowser tests scripts} [testenv:pyroma] basepython = python3 -skip_install = true passenv = -deps = -r{toxinidir}/misc/requirements/requirements-pyroma.txt +deps = + -r{toxinidir}/misc/requirements/requirements-pip.txt + -r{toxinidir}/misc/requirements/requirements-pyroma.txt commands = {envdir}/bin/pyroma . [testenv:check-manifest] basepython = python3 -skip_install = true passenv = -deps = -r{toxinidir}/misc/requirements/requirements-check-manifest.txt +deps = + -r{toxinidir}/misc/requirements/requirements-pip.txt + -r{toxinidir}/misc/requirements/requirements-check-manifest.txt commands = {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__' [testenv:docs] basepython = python3 -skip_install = true whitelist_externals = git passenv = TRAVIS_PULL_REQUEST deps = @@ -156,7 +159,6 @@ commands = # PYTHON is actually required when using this env, but the entire tox.ini would # fail if we didn't have a fallback defined. basepython = {env:PYTHON:}/python.exe -skip_install = true deps = -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/requirements.txt @@ -168,7 +170,6 @@ commands = [testenv:pyinstaller] basepython = python3 -skip_install = true deps = -r{toxinidir}/misc/requirements/requirements-pip.txt -r{toxinidir}/requirements.txt @@ -178,7 +179,6 @@ commands = {envbindir}/pyinstaller --noconfirm misc/qutebrowser.spec [testenv:eslint] -skip_install = True -deps = -r{toxinidir}/misc/requirements/requirements-pip.txt +deps = whitelist_externals = eslint -commands = eslint qutebrowser +commands = eslint --color qutebrowser/javascript