diff --git a/.coveragerc b/.coveragerc index ff714c43d..16bebb0cc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,6 +3,7 @@ branch = true omit = qutebrowser/__main__.py */__init__.py + qutebrowser/resources.py [report] exclude_lines = diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index c18927953..2981578e6 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -32,6 +32,7 @@ Added - New arguments `--basedir` and `--temp-basedir` (intended for debugging) to set a different base directory for all data, which allows multiple invocations. - New argument `--no-err-windows` to suppress all error windows. - New visual/caret mode (bound to `v`) to select text by keyboard. +- New setting `tabs -> mousewheel-tab-switching` to control mousewheel behavior on the tab bar. Changed ~~~~~~~ @@ -73,6 +74,7 @@ Fixed - Various fixes for deprecated key bindings and auto-migrations. - Workaround for qutebrowser not starting when there are NUL-bytes in the history (because of a currently unknown bug) - Fixed handling of keybindings containing Ctrl/Meta on OS X. +- Fixed crash when downloading an URL without filename (e.g. magnet links) via "Save as...". https://github.com/The-Compiler/qutebrowser/releases/tag/v0.2.1[v0.2.1] ----------------------------------------------------------------------- diff --git a/README.asciidoc b/README.asciidoc index 8742f11cb..ad7a95ff2 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -150,6 +150,7 @@ Contributors, sorted by the number of commits in descending order: * Error 800 * Brian Jackson * sbinix +* Tobias Patzl * Johannes Altmanninger * Samir Benmendil * Regina Hug diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 46539c3b7..d3c571aa3 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -111,6 +111,7 @@ |<>|Spacing between tab edge and indicator. |<>|Whether to open windows instead of tabs. |<>|The format to use for the tab title. The following placeholders are defined: +|<>|Switch between tabs using the mouse wheel. |============== .Quick reference for section ``storage'' @@ -1031,6 +1032,17 @@ The format to use for the tab title. The following placeholders are defined: Default: +pass:[{index}: {title}]+ +[[tabs-mousewheel-tab-switching]] +=== mousewheel-tab-switching +Switch between tabs using the mouse wheel. + +Valid values: + + * +true+ + * +false+ + +Default: +pass:[true]+ + == storage Settings related to cache and storage. diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc index ac3be3a9c..e0b22d378 100644 --- a/doc/quickstart.asciidoc +++ b/doc/quickstart.asciidoc @@ -11,6 +11,7 @@ What to do now * View the http://qutebrowser.org/img/cheatsheet-big.png[key binding cheatsheet] to make yourself familiar with the key bindings: + image:http://qutebrowser.org/img/cheatsheet-small.png["qutebrowser key binding cheatsheet",link="http://qutebrowser.org/img/cheatsheet-big.png"] +* Run `:adblock-update` to download adblock lists and activate adblocking. * If you just cloned the repository, you'll need to run `scripts/asciidoc2html.py` to generate the documentation. * Go to the link:qute://settings[settings page] to set up qutebrowser the way you want it. diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index ab055cbf4..790459f6a 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -686,8 +686,11 @@ class DownloadManager(QAbstractListModel): if fileobj is not None or filename is not None: return self.fetch_request(request, page, fileobj, filename, auto_remove, suggested_fn) - encoding = sys.getfilesystemencoding() - suggested_fn = utils.force_encoding(suggested_fn, encoding) + if suggested_fn is None: + suggested_fn = 'qutebrowser-download' + else: + encoding = sys.getfilesystemencoding() + suggested_fn = utils.force_encoding(suggested_fn, encoding) q = self._prepare_question() q.default = _path_suggestion(suggested_fn) message_bridge = objreg.get('message-bridge', scope='window', diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index cb3a47be7..02b8c6008 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -522,6 +522,10 @@ def data(readonly=False): "* `{index}`: The index of this tab.\n" "* `{id}`: The internal tab ID of this tab."), + ('mousewheel-tab-switching', + SettingValue(typ.Bool(), 'true'), + "Switch between tabs using the mouse wheel."), + readonly=readonly )), diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index c465c8ca4..ba5a5c725 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -577,3 +577,14 @@ class TabbedBrowser(tabwidget.TabWidget): """ super().resizeEvent(e) self.resized.emit(self.geometry()) + + def wheelEvent(self, e): + """Override wheelEvent of QWidget to forward it to the focused tab. + + Args: + e: The QWheelEvent + """ + if self._now_focused is not None: + self._now_focused.wheelEvent(e) + else: + e.ignore() diff --git a/qutebrowser/mainwindow/tabwidget.py b/qutebrowser/mainwindow/tabwidget.py index df44ebbba..bbbfdf045 100644 --- a/qutebrowser/mainwindow/tabwidget.py +++ b/qutebrowser/mainwindow/tabwidget.py @@ -480,6 +480,19 @@ class TabBar(QTabBar): new_idx = super().insertTab(idx, icon, '') self.set_page_title(new_idx, text) + def wheelEvent(self, e): + """Override wheelEvent to make the action configurable. + + Args: + e: The QWheelEvent + """ + if config.get('tabs', 'mousewheel-tab-switching'): + super().wheelEvent(e) + else: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=self._win_id) + tabbed_browser.wheelEvent(e) + class TabBarStyle(QCommonStyle): diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 939e1ed81..6573306ab 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -131,7 +131,7 @@ def ensure_valid(obj): def ensure_not_null(obj): """Ensure a Qt object with an .isNull() method is not null.""" if obj.isNull(): - raise QtValueError(obj) + raise QtValueError(obj, null=True) def check_qdatastream(stream): @@ -180,7 +180,7 @@ def deserialize_stream(stream, obj): def savefile_open(filename, binary=False, encoding='utf-8'): """Context manager to easily use a QSaveFile.""" f = QSaveFile(filename) - new_f = None + cancelled = False try: ok = f.open(QIODevice.WriteOnly) if not ok: @@ -192,13 +192,14 @@ def savefile_open(filename, binary=False, encoding='utf-8'): yield new_f except: f.cancelWriting() + cancelled = True raise + else: + new_f.flush() finally: - if new_f is not None: - new_f.flush() commit_ok = f.commit() - if not commit_ok: - raise OSError(f.errorString()) + if not commit_ok and not cancelled: + raise OSError("Commit failed!") @contextlib.contextmanager @@ -221,27 +222,58 @@ class PyQIODevice(io.BufferedIOBase): """Wrapper for a QIODevice which provides a python interface. Attributes: - _dev: The underlying QIODevice. + dev: The underlying QIODevice. """ # pylint: disable=missing-docstring def __init__(self, dev): - self._dev = dev + self.dev = dev def __len__(self): - return self._dev.size() + return self.dev.size() def _check_open(self): - """Check if the device is open, raise OSError if not.""" - if not self._dev.isOpen(): - raise OSError("IO operation on closed device!") + """Check if the device is open, raise ValueError if not.""" + if not self.dev.isOpen(): + raise ValueError("IO operation on closed device!") def _check_random(self): """Check if the device supports random access, raise OSError if not.""" if not self.seekable(): raise OSError("Random access not allowed!") + def _check_readable(self): + """Check if the device is readable, raise OSError if not.""" + if not self.dev.isReadable(): + raise OSError("Trying to read unreadable file!") + + def _check_writable(self): + """Check if the device is writable, raise OSError if not.""" + if not self.writable(): + raise OSError("Trying to write to unwritable file!") + + def open(self, mode): + """Open the underlying device and ensure opening succeeded. + + Raises OSError if opening failed. + + Args: + mode: QIODevice::OpenMode flags. + + Return: + A contextlib.closing() object so this can be used as + contextmanager. + """ + ok = self.dev.open(mode) + if not ok: + raise OSError(self.dev.errorString()) + return contextlib.closing(self) + + def close(self): + """Close the underlying device.""" + self.dev.close() + def fileno(self): raise io.UnsupportedOperation @@ -249,85 +281,102 @@ class PyQIODevice(io.BufferedIOBase): self._check_open() self._check_random() if whence == io.SEEK_SET: - ok = self._dev.seek(offset) + ok = self.dev.seek(offset) elif whence == io.SEEK_CUR: - ok = self._dev.seek(self.tell() + offset) + ok = self.dev.seek(self.tell() + offset) elif whence == io.SEEK_END: - ok = self._dev.seek(len(self) + offset) + ok = self.dev.seek(len(self) + offset) else: raise io.UnsupportedOperation("whence = {} is not " "supported!".format(whence)) if not ok: - raise OSError(self._dev.errorString()) + raise OSError("seek failed!") def truncate(self, size=None): # pylint: disable=unused-argument raise io.UnsupportedOperation - def close(self): - self._dev.close() - @property def closed(self): - return not self._dev.isOpen() + return not self.dev.isOpen() def flush(self): self._check_open() - self._dev.waitForBytesWritten(-1) + self.dev.waitForBytesWritten(-1) def isatty(self): self._check_open() return False def readable(self): - return self._dev.isReadable() + return self.dev.isReadable() def readline(self, size=-1): self._check_open() - if size == -1: - size = 0 - return self._dev.readLine(size) + self._check_readable() + + if size < 0: + qt_size = 0 # no maximum size + elif size == 0: + return QByteArray() + else: + qt_size = size + 1 # Qt also counts the NUL byte + + if self.dev.canReadLine(): + buf = self.dev.readLine(qt_size) + else: + if size < 0: + buf = self.dev.readAll() + else: + buf = self.dev.read(size) + + if buf is None: + raise OSError(self.dev.errorString()) + return buf def seekable(self): - return not self._dev.isSequential() + return not self.dev.isSequential() def tell(self): self._check_open() self._check_random() - return self._dev.pos() + return self.dev.pos() def writable(self): - return self._dev.isWritable() - - def readinto(self, b): - self._check_open() - return self._dev.read(b, len(b)) + return self.dev.isWritable() def write(self, b): self._check_open() - num = self._dev.write(b) + self._check_writable() + num = self.dev.write(b) if num == -1 or num < len(b): - raise OSError(self._dev.errorString()) + raise OSError(self.dev.errorString()) return num - def read(self, size): + def read(self, size=-1): self._check_open() - buf = bytes() - num = self._dev.read(buf, size) - if num == -1: - raise OSError(self._dev.errorString()) - return num + self._check_readable() + if size < 0: + buf = self.dev.readAll() + else: + buf = self.dev.read(size) + if buf is None: + raise OSError(self.dev.errorString()) + return buf class QtValueError(ValueError): """Exception which gets raised by ensure_valid.""" - def __init__(self, obj): + def __init__(self, obj, null=False): try: self.reason = obj.errorString() except AttributeError: self.reason = None - err = "{} is not valid".format(obj) + if null: + err = "{} is null".format(obj) + else: + err = "{} is not valid".format(obj) if self.reason: err += ": {}".format(self.reason) super().__init__(err) diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 3ed82b0db..143e7cfc5 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -94,6 +94,8 @@ def _is_url_naive(urlstr): True if the URL really is a URL, False otherwise. """ url = qurl_from_user_input(urlstr) + assert url.isValid() + if not utils.raises(ValueError, ipaddress.ip_address, urlstr): # Valid IPv4/IPv6 address return True @@ -104,9 +106,7 @@ def _is_url_naive(urlstr): if not QHostAddress(urlstr).isNull(): return False - if not url.isValid(): - return False - elif '.' in url.host(): + if '.' in url.host(): return True else: return False @@ -122,9 +122,7 @@ def _is_url_dns(urlstr): True if the URL really is a URL, False otherwise. """ url = qurl_from_user_input(urlstr) - if not url.isValid(): - log.url.debug("Invalid URL -> False") - return False + assert url.isValid() if (utils.raises(ValueError, ipaddress.ip_address, urlstr) and not QHostAddress(urlstr).isNull()): @@ -246,16 +244,13 @@ def is_url(urlstr): return False if not qurl_userinput.isValid(): + # This will also catch URLs containing spaces. return False if _has_explicit_scheme(qurl): # URLs with explicit schemes are always URLs log.url.debug("Contains explicit scheme") url = True - elif ' ' in urlstr: - # A URL will never contain a space - log.url.debug("Contains space -> no URL") - url = False elif qurl_userinput.host() in ('localhost', '127.0.0.1', '::1'): log.url.debug("Is localhost.") url = True @@ -274,7 +269,7 @@ def is_url(urlstr): else: raise ValueError("Invalid autosearch value") log.url.debug("url = {}".format(url)) - return url and qurl_userinput.isValid() + return url def qurl_from_user_input(urlstr): diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 6823685d7..827762af4 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -130,7 +130,7 @@ def _module_versions(): try: import sipconfig # pylint: disable=import-error,unused-variable except ImportError: - pass + lines.append('SIP: ?') else: try: lines.append('SIP: {}'.format( diff --git a/scripts/link_pyqt.py b/scripts/link_pyqt.py index ee1ebf2de..7c8a77b58 100644 --- a/scripts/link_pyqt.py +++ b/scripts/link_pyqt.py @@ -70,13 +70,16 @@ def link_pyqt(sys_path, venv_path): if not globbed_sip: raise Error("Did not find sip in {}!".format(sys_path)) - files = ['PyQt5'] - files += [os.path.basename(e) for e in globbed_sip] - for fn in files: + files = [('PyQt5', True), ('sipconfig.py', False)] + files += [(os.path.basename(e), True) for e in globbed_sip] + for fn, required in files: source = os.path.join(sys_path, fn) dest = os.path.join(venv_path, fn) if not os.path.exists(source): - raise FileNotFoundError(source) + if required: + raise FileNotFoundError(source) + else: + continue if os.path.exists(dest): if os.path.isdir(dest) and not os.path.islink(dest): shutil.rmtree(dest) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 123b2a412..d5fab2ed1 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -157,14 +157,6 @@ class TestConfigParser: self.cfg.get('general', 'bar') # pylint: disable=bad-config-call -def keyconfig_deprecated_test_cases(): - """Generator yielding test cases (command, rgx) for TestKeyConfigParser.""" - for sect in configdata.KEY_DATA.values(): - for command in sect: - for rgx, _repl in configdata.CHANGED_KEY_COMMANDS: - yield (command, rgx) - - class TestKeyConfigParser: """Test config.parsers.keyconf.KeyConfigParser.""" @@ -185,10 +177,13 @@ class TestKeyConfigParser: with pytest.raises(keyconf.KeyConfigError): kcp._read_command(cmdline_test.cmd) - @pytest.mark.parametrize('command, rgx', keyconfig_deprecated_test_cases()) - def test_default_config_no_deprecated(self, command, rgx): + @pytest.mark.parametrize('rgx', [rgx for rgx, _repl + in configdata.CHANGED_KEY_COMMANDS]) + def test_default_config_no_deprecated(self, rgx): """Make sure the default config contains no deprecated commands.""" - assert rgx.match(command) is None + for sect in configdata.KEY_DATA.values(): + for command in sect: + assert rgx.match(command) is None @pytest.mark.parametrize( 'old, new_expected', diff --git a/tests/misc/test_lineparser.py b/tests/misc/test_lineparser.py index 613ef3d65..17ae8415b 100644 --- a/tests/misc/test_lineparser.py +++ b/tests/misc/test_lineparser.py @@ -72,7 +72,7 @@ class LineParserWrapper: return True -class TestableAppendLineParser(LineParserWrapper, +class AppendLineParserTestable(LineParserWrapper, lineparsermod.AppendLineParser): """Wrapper over AppendLineParser to make it testable.""" @@ -80,14 +80,14 @@ class TestableAppendLineParser(LineParserWrapper, pass -class TestableLineParser(LineParserWrapper, lineparsermod.LineParser): +class LineParserTestable(LineParserWrapper, lineparsermod.LineParser): """Wrapper over LineParser to make it testable.""" pass -class TestableLimitLineParser(LineParserWrapper, +class LimitLineParserTestable(LineParserWrapper, lineparsermod.LimitLineParser): """Wrapper over LimitLineParser to make it testable.""" @@ -137,7 +137,7 @@ class TestAppendLineParser: @pytest.fixture def lineparser(self): """Fixture to get an AppendLineParser for tests.""" - lp = TestableAppendLineParser('this really', 'does not matter') + lp = AppendLineParserTestable('this really', 'does not matter') lp.new_data = self.BASE_DATA lp.save() return lp @@ -178,7 +178,7 @@ class TestAppendLineParser: def test_get_recent_none(self): """Test get_recent with no data.""" - linep = TestableAppendLineParser('this really', 'does not matter') + linep = AppendLineParserTestable('this really', 'does not matter') assert linep.get_recent() == [] def test_get_recent_little(self, lineparser): diff --git a/tox.ini b/tox.ini index cf1d596d6..f39af4ee3 100644 --- a/tox.ini +++ b/tox.ini @@ -19,17 +19,18 @@ usedevelop = true setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms passenv = DISPLAY XAUTHORITY HOME deps = + -r{toxinidir}/requirements.txt py==1.4.27 pytest==2.7.1 pytest-capturelog==0.7 pytest-qt==1.3.0 pytest-mock==0.5 - pytest-html==1.2 + pytest-html==1.3.1 # We don't use {[testenv:mkvenv]commands} here because that seems to be broken # on Ubuntu Trusty. commands = {envpython} scripts/link_pyqt.py --tox {envdir} - {envpython} -m py.test --strict {posargs} + {envpython} -m py.test --strict -rfEsw {posargs} [testenv:coverage] passenv = DISPLAY XAUTHORITY HOME @@ -40,7 +41,7 @@ deps = cov-core==1.15.0 commands = {[testenv:mkvenv]commands} - {envpython} -m py.test --strict --cov qutebrowser --cov-report term --cov-report html {posargs} + {envpython} -m py.test --strict -rfEswx -v --cov qutebrowser --cov-report term --cov-report html {posargs} [testenv:misc] commands = @@ -96,7 +97,7 @@ commands = [testenv:check-manifest] skip_install = true deps = - check-manifest==0.24 + check-manifest==0.25 commands = {[testenv:mkvenv]commands} {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__'