diff --git a/BUGS b/BUGS index a8c7796e6..97f11f3e8 100644 --- a/BUGS +++ b/BUGS @@ -10,6 +10,9 @@ Bugs e.g. when trying to open a link with %20 interactively, or even via click. +- When quitting while being asked for a download filename: segfault / memory + corruption + - When following a hint: QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once. diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 39f1a85e7..264cc4c3f 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -53,6 +53,8 @@ from qutebrowser.commands.managers import CommandManager, SearchManager from qutebrowser.config.iniparsers import ReadWriteConfigParser from qutebrowser.config.lineparser import LineConfigParser from qutebrowser.browser.cookies import CookieJar +from qutebrowser.browser.downloads import DownloadManager +from qutebrowser.models.downloadmodel import DownloadModel from qutebrowser.utils.message import MessageBridge from qutebrowser.utils.misc import (get_standard_dir, actute_warning, get_qt_args) @@ -132,6 +134,8 @@ class Application(QApplication): self.networkmanager = NetworkManager(self.cookiejar) self.commandmanager = CommandManager() self.searchmanager = SearchManager() + self.downloadmanager = DownloadManager() + self.downloadmodel = DownloadModel(self.downloadmanager) self.mainwindow = MainWindow() self.modeman.mainwindow = self.mainwindow @@ -386,6 +390,9 @@ class Application(QApplication): cmd.update_completion.connect(completer.on_update_completion) completer.change_completed_part.connect(cmd.on_change_completed_part) + # downloads + tabs.start_download.connect(self.downloadmanager.fetch) + def _recover_pages(self): """Try to recover all open pages. diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py new file mode 100644 index 000000000..f91749560 --- /dev/null +++ b/qutebrowser/browser/downloads.py @@ -0,0 +1,386 @@ +# Copyright 2014 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 . + +"""Download manager.""" + +import os +import os.path +from collections import deque + +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer +from PyQt5.QtNetwork import QNetworkReply + +import qutebrowser.config.config as config +import qutebrowser.utils.message as message +from qutebrowser.utils.log import downloads as logger +from qutebrowser.utils.usertypes import PromptMode, Question +from qutebrowser.utils.misc import (interpolate_color, format_seconds, + format_size) + + +class DownloadItem(QObject): + + """A single download currently running. + + Class attributes: + SPEED_REFRESH_INTERVAL: How often to refresh the speed, in msec. + SPEED_AVG_WINDOW: How many seconds of speed data to average to + estimate the remaining time. + + Attributes: + reply: The QNetworkReply associated with this download. + percentage: How many percent were downloaded successfully. + None if unknown. + bytes_done: How many bytes there are already downloaded. + bytes_total: The total count of bytes. + None if the total is unknown. + speed: The current download speed, in bytes per second. + remaining_time: The time remaining for the download. + None if not enough data is available yet. + fileobj: The file object to download the file to. + filename: The filename of the download. + is_cancelled: Whether the download was cancelled. + speed_avg: A rolling average of speeds. + _last_done: The count of bytes which where downloaded when calculating + the speed the last time. + + Signals: + data_changed: The downloads metadata changed. + finished: The download was finished. + cancelled: The download was cancelled. + error: An error with the download occured. + arg: The error message as string. + """ + + SPEED_REFRESH_INTERVAL = 500 + SPEED_AVG_WINDOW = 30 + data_changed = pyqtSignal() + finished = pyqtSignal() + error = pyqtSignal(str) + cancelled = pyqtSignal() + + def __init__(self, reply, parent=None): + """Constructor. + + Args: + reply: The QNetworkReply to download. + """ + super().__init__(parent) + self.reply = reply + self.bytes_total = None + self.speed = 0 + self.basename = '???' + samples = int(self.SPEED_AVG_WINDOW * + (1000 / self.SPEED_REFRESH_INTERVAL)) + self.speed_avg = deque(maxlen=samples) + self.fileobj = None + self.filename = None + self.is_cancelled = False + self._do_delayed_write = False + self.bytes_done = 0 + self._last_done = 0 + reply.setReadBufferSize(16 * 1024 * 1024) + reply.downloadProgress.connect(self.on_download_progress) + reply.finished.connect(self.on_reply_finished) + reply.error.connect(self.on_reply_error) + reply.readyRead.connect(self.on_ready_read) + self.timer = QTimer() + self.timer.timeout.connect(self.update_speed) + self.timer.setInterval(self.SPEED_REFRESH_INTERVAL) + self.timer.start() + + def __str__(self): + """Get the download as a string. + + Example: foo.pdf [699.2kB/s|0.34|16%|4.253/25.124] + """ + perc = 0 if self.percentage is None else round(self.percentage) + remaining = (format_seconds(self.remaining_time) + if self.remaining_time is not None else '?') + speed = format_size(self.speed, suffix='B/s') + down = format_size(self.bytes_done, suffix='B') + total = format_size(self.bytes_total, suffix='B') + return ('{name} [{speed:>10}|{remaining:>5}|{perc:>2}%|' + '{down}/{total}]'.format(name=self.basename, speed=speed, + remaining=remaining, perc=perc, + down=down, total=total)) + + def _die(self, msg): + """Abort the download and emit an error.""" + self.error.emit(msg) + self.reply.abort() + self.reply.deleteLater() + if self.fileobj is not None: + try: + self.fileobj.close() + except OSError as e: + self.error.emit(e.strerror) + self.finished.emit() + + @property + def percentage(self): + """Property to get the current download percentage.""" + if self.bytes_total == 0 or self.bytes_total is None: + return None + else: + return 100 * self.bytes_done / self.bytes_total + + @property + def remaining_time(self): + """Property to get the remaining download time in seconds.""" + if self.bytes_total is None or not self.speed_avg: + # No average yet or we don't know the total size. + return None + remaining_bytes = self.bytes_total - self.bytes_done + avg = sum(self.speed_avg) / len(self.speed_avg) + if avg == 0: + # Download stalled + return None + else: + return remaining_bytes / avg + + def bg_color(self): + """Background color to be shown.""" + start = config.get('colors', 'downloads.bg.start') + stop = config.get('colors', 'downloads.bg.stop') + system = config.get('colors', 'downloads.bg.system') + if self.percentage is None: + return start + else: + return interpolate_color(start, stop, self.percentage, system) + + def cancel(self): + """Cancel the download.""" + logger.debug("cancelled") + self.cancelled.emit() + self.is_cancelled = True + self.reply.abort() + self.reply.deleteLater() + if self.fileobj is not None: + self.fileobj.close() + if self.filename is not None: + os.remove(self.filename) + self.finished.emit() + + def set_filename(self, filename): + """Set the filename to save the download to. + + Args: + filename: The full filename to save the download to. + None: special value to stop the download. + """ + if self.filename is not None: + raise ValueError("Filename was already set! filename: {}, " + "existing: {}".format(filename, self.filename)) + self.filename = filename + self.basename = os.path.basename(filename) + try: + self.fileobj = open(filename, 'wb') + if self._do_delayed_write: + # Downloading to the buffer in RAM has already finished so we + # write out the data and clean up now. + self.delayed_write() + else: + # Since the buffer already might be full, on_ready_read might + # not be called at all anymore, so we force it here to flush + # the buffer and continue receiving new data. + self.on_ready_read() + except OSError as e: + self._die(e.strerror) + + def delayed_write(self): + """Write buffered data to disk and finish the QNetworkReply.""" + logger.debug("Doing delayed write...") + self._do_delayed_write = False + self.fileobj.write(self.reply.readAll()) + self.fileobj.close() + self.reply.close() + self.reply.deleteLater() + self.finished.emit() + logger.debug("Download finished") + + @pyqtSlot(int, int) + def on_download_progress(self, bytes_done, bytes_total): + """Upload local variables when the download progress changed. + + Args: + bytes_done: How many bytes are downloaded. + bytes_total: How many bytes there are to download in total. + """ + if bytes_total == -1: + bytes_total = None + self.bytes_done = bytes_done + self.bytes_total = bytes_total + self.data_changed.emit() + + @pyqtSlot() + def on_reply_finished(self): + """Clean up when the download was finished. + + Note when this gets called, only the QNetworkReply has finished. This + doesn't mean the download (i.e. writing data to the disk) is finished + as well. Therefore, we can't close() the QNetworkReply in here yet. + """ + self.bytes_done = self.bytes_total + self.timer.stop() + if self.is_cancelled: + return + logger.debug("Reply finished, fileobj {}".format(self.fileobj)) + if self.fileobj is None: + # We'll handle emptying the buffer and cleaning up as soon as the + # filename is set. + self._do_delayed_write = True + else: + # We can do a "delayed" write immediately to empty the buffer and + # clean up. + self.delayed_write() + + @pyqtSlot() + def on_ready_read(self): + """Read available data and save file when ready to read.""" + if self.fileobj is None: + # No filename has been set yet, so we don't empty the buffer. + return + try: + self.fileobj.write(self.reply.readAll()) + except OSError as e: + self._die(e.strerror) + + @pyqtSlot(int) + def on_reply_error(self, code): + """Handle QNetworkReply errors.""" + if code == QNetworkReply.OperationCanceledError: + return + else: + self.error.emit(self.reply.errorString()) + + @pyqtSlot() + def update_speed(self): + """Recalculate the current download speed.""" + delta = self.bytes_done - self._last_done + self.speed = delta * 1000 / self.SPEED_REFRESH_INTERVAL + self.speed_avg.append(self.speed) + self._last_done = self.bytes_done + self.data_changed.emit() + + +class DownloadManager(QObject): + + """Manager for running downloads. + + Attributes: + downloads: A list of active DownloadItems. + questions: A list of Question objects to not GC them. + + Signals: + download_about_to_be_added: A new download will be added. + arg: The index of the new download. + download_added: A new download was added. + download_about_to_be_finished: A download will be finished and removed. + arg: The index of the new download. + download_finished: A download was finished and removed. + data_changed: The data to be displayed in a model changed. + arg: The index of the download which changed. + """ + + download_about_to_be_added = pyqtSignal(int) + download_added = pyqtSignal() + download_about_to_be_finished = pyqtSignal(int) + download_finished = pyqtSignal() + data_changed = pyqtSignal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.downloads = [] + self.questions = [] + + def _get_filename(self, reply): + """Get a suitable filename to download a file to. + + Args: + reply: The QNetworkReply to get a filename for. + """ + filename = None + # First check if the Content-Disposition header has a filename + # attribute. + if reply.hasRawHeader('Content-Disposition'): + header = reply.rawHeader('Content-Disposition') + data = header.split(':', maxsplit=1)[1].strip() + for pair in data.split(';'): + if '=' in pair: + key, value = pair.split('=') + if key == 'filename': + filename = value.strip('"') + break + # Then try to get filename from url + if not filename: + filename = reply.url().path() + # If that fails as well, use a fallback + if not filename: + filename = 'qutebrowser-download' + return os.path.basename(filename) + + @pyqtSlot('QNetworkReply') + def fetch(self, reply): + """Download a QNetworkReply to disk. + + Args: + reply: The QNetworkReply to download. + """ + suggested_filename = self._get_filename(reply) + download_location = config.get('storage', 'download-directory') + suggested_filepath = os.path.join(download_location, + suggested_filename) + logger.debug("fetch: {} -> {}".format(reply.url(), suggested_filepath)) + download = DownloadItem(reply) + download.finished.connect(self.on_finished) + download.data_changed.connect(self.on_data_changed) + download.error.connect(self.on_error) + download.basename = suggested_filename + self.download_about_to_be_added.emit(len(self.downloads) + 1) + self.downloads.append(download) + self.download_added.emit() + + q = Question() + q.text = "Save file to:" + q.mode = PromptMode.text + q.default = suggested_filepath + q.answered.connect(download.set_filename) + q.cancelled.connect(download.cancel) + self.questions.append(q) + download.cancelled.connect(q.abort) + message.instance().question.emit(q, False) + + @pyqtSlot() + def on_finished(self): + """Remove finished download.""" + logger.debug("on_finished: {}".format(self.sender())) + idx = self.downloads.index(self.sender()) + self.download_about_to_be_finished.emit(idx) + del self.downloads[idx] + self.download_finished.emit() + + @pyqtSlot() + def on_data_changed(self): + """Emit data_changed signal when download data changed.""" + idx = self.downloads.index(self.sender()) + self.data_changed.emit(idx) + + @pyqtSlot(str) + def on_error(self, msg): + """Display error message on download errors.""" + message.error("Download error: {}".format(msg), queue=True) diff --git a/qutebrowser/browser/webpage.py b/qutebrowser/browser/webpage.py index 0ff2ebb01..336e47ebd 100644 --- a/qutebrowser/browser/webpage.py +++ b/qutebrowser/browser/webpage.py @@ -18,7 +18,7 @@ """The main browser widgets.""" import sip -from PyQt5.QtCore import QCoreApplication +from PyQt5.QtCore import QCoreApplication, pyqtSignal, pyqtSlot from PyQt5.QtNetwork import QNetworkReply from PyQt5.QtWidgets import QFileDialog from PyQt5.QtPrintSupport import QPrintDialog @@ -39,8 +39,13 @@ class BrowserPage(QWebPage): Attributes: _extension_handlers: Mapping of QWebPage extensions to their handlers. network_access_manager: The QNetworkAccessManager used. + + Signals: + start_download: Emitted when a file should be downloaded. """ + start_download = pyqtSignal('QNetworkReply*') + def __init__(self, parent=None): super().__init__(parent) self._extension_handlers = { @@ -49,7 +54,9 @@ class BrowserPage(QWebPage): } self.setNetworkAccessManager( QCoreApplication.instance().networkmanager) + self.setForwardUnsupportedContent(True) self.printRequested.connect(self.on_print_requested) + self.downloadRequested.connect(self.on_download_requested) def _handle_errorpage(self, opt, out): """Display an error page if needed. @@ -118,6 +125,17 @@ class BrowserPage(QWebPage): printdiag = QPrintDialog() printdiag.open(lambda: frame.print(printdiag.printer())) + @pyqtSlot('QNetworkRequest') + def on_download_requested(self, request): + """Called when the user wants to download a link. + + Emit: + start_download: Emitted with the QNetworkReply associated with the + passed request. + """ + reply = self.networkAccessManager().get(request) + self.start_download.emit(reply) + def userAgentForUrl(self, url): """Override QWebPage::userAgentForUrl to customize the user agent.""" ua = config.get('network', 'user-agent') diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 42d6d4c13..7554a4178 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -438,6 +438,11 @@ DATA = OrderedDict([ )), ('storage', sect.KeyValue( + ('download-directory', + SettingValue(types.Directory(none=True), ''), + "The directory to save downloads to. An empty value selects a " + "sensible os-specific default."), + ('maximum-pages-in-cache', SettingValue(types.Int(none=True, minval=0, maxval=MAXVALS['int']), ''), @@ -889,6 +894,26 @@ DATA = OrderedDict([ 'left bottom, color-stop(0%,#FFF785), ' 'color-stop(100%,#FFC542))'), "Background color for hints."), + + ('downloads.fg', + SettingValue(types.QtColor(), '#ffffff'), + "Foreground color for downloads."), + + ('downloads.bg.bar', + SettingValue(types.Color(), 'black'), + "Background color for the download bar."), + + ('downloads.bg.start', + SettingValue(types.QtColor(), '#0000aa'), + "Color gradient start for downloads."), + + ('downloads.bg.stop', + SettingValue(types.QtColor(), '#00aa00'), + "Color gradient end for downloads."), + + ('downloads.bg.system', + SettingValue(types.ColorSystem(), 'rgb'), + "Color gradient interpolation system for downloads."), )), ('fonts', sect.KeyValue( @@ -911,6 +936,10 @@ DATA = OrderedDict([ SettingValue(types.Font(), '8pt ${_monospace}'), "Font used in the statusbar."), + ('downloads', + SettingValue(types.Font(), '8pt ${_monospace}'), + "Font used for the downloadbar."), + ('hints', SettingValue(types.Font(), 'bold 12px Monospace'), "Font used for the hints."), diff --git a/qutebrowser/config/conftypes.py b/qutebrowser/config/conftypes.py index 2095db8ac..f6dfd6a09 100644 --- a/qutebrowser/config/conftypes.py +++ b/qutebrowser/config/conftypes.py @@ -22,11 +22,12 @@ import shlex import os.path from sre_constants import error as RegexError -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, QStandardPaths from PyQt5.QtGui import QColor from PyQt5.QtNetwork import QNetworkProxy import qutebrowser.commands.utils as cmdutils +from qutebrowser.utils.misc import get_standard_dir class ValidationError(ValueError): @@ -433,6 +434,42 @@ class Command(BaseType): return out +class ColorSystem(BaseType): + + """Color systems for interpolation.""" + + valid_values = ValidValues(('rgb', "Interpolate in the RGB color system."), + ('hsv', "Interpolate in the HSV color system."), + ('hsl', "Interpolate in the HSV color system.")) + + def validate(self, value): + super().validate(value.lower()) + + def transform(self, value): + mapping = { + 'rgb': QColor.Rgb, + 'hsv': QColor.Hsv, + 'hsl': QColor.Hsl, + } + return mapping[value.lower()] + + +class QtColor(BaseType): + + """Base class for QColor.""" + + typestr = 'qcolor' + + def validate(self, value): + if QColor.isValidColor(value): + pass + else: + raise ValidationError(value, "must be a valid color") + + def transform(self, value): + return QColor(value) + + class CssColor(BaseType): """Base class for a CSS color value.""" @@ -530,6 +567,31 @@ class File(BaseType): raise ValidationError(value, "must be a valid file!") +class Directory(BaseType): + + """A directory on the local filesystem. + + Attributes: + none: Whether to accept empty values as None. + """ + + typestr = 'directory' + + def __init__(self, none=False): + self.none = none + + def validate(self, value): + if self.none and not value: + return + if not os.path.isdir(value): + raise ValidationError(value, "must be a valid directory!") + + def transform(self, value): + if not value: + return get_standard_dir(QStandardPaths.DownloadLocation) + return value + + class WebKitBytes(BaseType): """A size with an optional suffix. diff --git a/qutebrowser/config/style.py b/qutebrowser/config/style.py index d020a0851..e37dd4c8e 100644 --- a/qutebrowser/config/style.py +++ b/qutebrowser/config/style.py @@ -25,6 +25,7 @@ Module attributes: from functools import partial import qutebrowser.config.config as config +from qutebrowser.utils.log import style as logger _colordict = None @@ -60,7 +61,9 @@ def set_register_stylesheet(obj): obj: The object to set the stylesheet for and register. Must have a STYLESHEET attribute. """ - obj.setStyleSheet(get_stylesheet(obj.STYLESHEET)) + qss = get_stylesheet(obj.STYLESHEET) + logger.debug("stylesheet for {}:\n{}".format(obj.__class__.__name__, qss)) + obj.setStyleSheet(qss) config.instance().changed.connect(partial(_update_stylesheet, obj)) diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py new file mode 100644 index 000000000..1945e98b2 --- /dev/null +++ b/qutebrowser/models/downloadmodel.py @@ -0,0 +1,102 @@ +# Copyright 2014 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 . + +"""Glue code for qutebrowser.{browser,widgets}.download.""" + +from PyQt5.QtCore import (pyqtSlot, Qt, QVariant, QAbstractListModel, + QModelIndex) +from PyQt5.QtWidgets import QApplication + +import qutebrowser.config.config as config +from qutebrowser.utils.usertypes import enum + + +Role = enum('item', start=Qt.UserRole) + + +class DownloadModel(QAbstractListModel): + + """Glue model to show downloads in a QListView. + + Glue between qutebrowser.browser.download (DownloadManager) and + qutebrowser.widgets.download (DownloadView). + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.downloadmanager = QApplication.instance().downloadmanager + self.downloadmanager.download_about_to_be_added.connect( + lambda idx: self.beginInsertRows(QModelIndex(), idx, idx)) + self.downloadmanager.download_added.connect(self.endInsertRows) + self.downloadmanager.download_about_to_be_finished.connect( + lambda idx: self.beginRemoveRows(QModelIndex(), idx, idx)) + self.downloadmanager.download_finished.connect(self.endRemoveRows) + self.downloadmanager.data_changed.connect(self.on_data_changed) + + @pyqtSlot(int) + def on_data_changed(self, idx): + """Update view when DownloadManager data changed.""" + model_idx = self.index(idx, 0) + self.dataChanged.emit(model_idx, model_idx) + + def last_index(self): + """Get the last index in the model.""" + return self.index(self.rowCount() - 1) + + def headerData(self, section, orientation, role): + """Simple constant header.""" + if (section == 0 and orientation == Qt.Horizontal and + role == Qt.DisplayRole): + return "Downloads" + else: + return "" + + def data(self, index, role): + """Download data from DownloadManager.""" + if not index.isValid(): + return QVariant() + elif index.parent().isValid() or index.column() != 0: + return QVariant() + + try: + item = self.downloadmanager.downloads[index.row()] + except IndexError: + return QVariant() + if role == Qt.DisplayRole: + data = str(item) + elif role == Qt.ForegroundRole: + data = config.get('colors', 'downloads.fg') + elif role == Qt.BackgroundRole: + data = item.bg_color() + elif role == Role.item: + data = item + else: + data = QVariant() + return data + + def flags(self, _index): + """Override flags so items aren't selectable. + + The default would be Qt.ItemIsEnabled | Qt.ItemIsSelectable.""" + return Qt.ItemIsEnabled + + def rowCount(self, parent=QModelIndex()): + """Get count of active downloads.""" + if parent.isValid(): + # We don't have children + return 0 + return len(self.downloadmanager.downloads) diff --git a/qutebrowser/test/utils/test_misc.py b/qutebrowser/test/utils/test_misc.py index 289d76100..eda857162 100644 --- a/qutebrowser/test/utils/test_misc.py +++ b/qutebrowser/test/utils/test_misc.py @@ -30,6 +30,7 @@ from tempfile import mkdtemp from unittest import TestCase from PyQt5.QtCore import QStandardPaths, QCoreApplication +from PyQt5.QtGui import QColor import qutebrowser.utils.misc as utils from qutebrowser.test.helpers import environ_set_temp @@ -433,5 +434,159 @@ class GetQtArgsTests(TestCase): self.assertIn('foobar', qt_args) +class InterpolateColorTests(TestCase): + + """Tests for interpolate_color. + + Attributes: + white: The QColor white as a valid QColor for tests. + white: The QColor black as a valid QColor for tests. + """ + + def setUp(self): + self.white = QColor('white') + self.black = QColor('black') + + def test_invalid_start(self): + """Test an invalid start color.""" + with self.assertRaises(ValueError): + utils.interpolate_color(QColor(), self.white, 0) + + def test_invalid_end(self): + """Test an invalid end color.""" + with self.assertRaises(ValueError): + utils.interpolate_color(self.white, QColor(), 0) + + def test_invalid_percentage(self): + """Test an invalid percentage.""" + with self.assertRaises(ValueError): + utils.interpolate_color(self.white, self.white, -1) + with self.assertRaises(ValueError): + utils.interpolate_color(self.white, self.white, 101) + + def test_invalid_colorspace(self): + """Test an invalid colorspace.""" + with self.assertRaises(ValueError): + utils.interpolate_color(self.white, self.black, 10, QColor.Cmyk) + + def test_valid_percentages_rgb(self): + """Test 0% and 100% in the RGB colorspace.""" + white = utils.interpolate_color(self.white, self.black, 0, QColor.Rgb) + black = utils.interpolate_color(self.white, self.black, 100, + QColor.Rgb) + self.assertEqual(white, self.white) + self.assertEqual(black, self.black) + + def test_valid_percentages_hsv(self): + """Test 0% and 100% in the HSV colorspace.""" + white = utils.interpolate_color(self.white, self.black, 0, QColor.Hsv) + black = utils.interpolate_color(self.white, self.black, 100, + QColor.Hsv) + self.assertEqual(white, self.white) + self.assertEqual(black, self.black) + + def test_valid_percentages_hsl(self): + """Test 0% and 100% in the HSL colorspace.""" + white = utils.interpolate_color(self.white, self.black, 0, QColor.Hsl) + black = utils.interpolate_color(self.white, self.black, 100, + QColor.Hsl) + self.assertEqual(white, self.white) + self.assertEqual(black, self.black) + + def test_interpolation_rgb(self): + """Test an interpolation in the RGB colorspace.""" + color = utils.interpolate_color(QColor(0, 40, 100), QColor(0, 20, 200), + 50, QColor.Rgb) + self.assertEqual(color, QColor(0, 30, 150)) + + def test_interpolation_hsv(self): + """Test an interpolation in the HSV colorspace.""" + start = QColor() + stop = QColor() + start.setHsv(0, 40, 100) + stop.setHsv(0, 20, 200) + color = utils.interpolate_color(start, stop, 50, QColor.Hsv) + expected = QColor() + expected.setHsv(0, 30, 150) + self.assertEqual(color, expected) + + def test_interpolation_hsl(self): + """Test an interpolation in the HSL colorspace.""" + start = QColor() + stop = QColor() + start.setHsl(0, 40, 100) + stop.setHsl(0, 20, 200) + color = utils.interpolate_color(start, stop, 50, QColor.Hsl) + expected = QColor() + expected.setHsl(0, 30, 150) + self.assertEqual(color, expected) + + +class FormatSecondsTests(TestCase): + + """Tests for format_seconds. + + Class attributes: + TESTS: A list of (input, output) tuples. + """ + + TESTS = [ + (-1, '-0:01'), + (0, '0:00'), + (59, '0:59'), + (60, '1:00'), + (60.4, '1:00'), + (61, '1:01'), + (-61, '-1:01'), + (3599, '59:59'), + (3600, '1:00:00'), + (3601, '1:00:01'), + (36000, '10:00:00'), + ] + + def test_format_seconds(self): + """Test format_seconds with several tests.""" + for seconds, out in self.TESTS: + self.assertEqual(utils.format_seconds(seconds), out, seconds) + + +class FormatSizeTests(TestCase): + + """Tests for format_size. + + Class attributes: + TESTS: A list of (input, output) tuples. + """ + + TESTS = [ + (-1024, '-1.00k'), + (-1, '-1.00'), + (0, '0.00'), + (1023, '1023.00'), + (1024, '1.00k'), + (1034.24, '1.01k'), + (1024 * 1024 * 2, '2.00M'), + (1024 ** 10, '1024.00Y'), + (None, '?.??'), + ] + + def test_format_size(self): + """Test format_size with several tests.""" + for size, out in self.TESTS: + self.assertEqual(utils.format_size(size), out, size) + + def test_suffix(self): + """Test the suffix option.""" + for size, out in self.TESTS: + self.assertEqual(utils.format_size(size, suffix='B'), out + 'B', + size) + + def test_base(self): + """Test with an alternative base.""" + kilo_tests = [(999, '999.00'), (1000, '1.00k'), (1010, '1.01k')] + for size, out in kilo_tests: + self.assertEqual(utils.format_size(size, base=1000), out, size) + + if __name__ == '__main__': unittest.main() diff --git a/qutebrowser/utils/log.py b/qutebrowser/utils/log.py index 4c97206ba..ff1f03093 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -66,8 +66,10 @@ init = getLogger('init') signals = getLogger('signals') hints = getLogger('hints') keyboard = getLogger('keyboard') +downloads = getLogger('downloads') js = getLogger('js') qt = getLogger('qt') +style = getLogger('style') ram_handler = None diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index f3d3ee786..e8c567c49 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -88,21 +88,25 @@ def alert(message): instance().question.emit(q, True) -def question(message, mode, handler, default=None): +def question(message, mode, handler, cancelled_handler=None, default=None): """Ask an async question in the statusbar. Args: message: The message to display to the user. mode: A PromptMode. handler: The function to get called with the answer as argument. + cancelled_handler: The function to get called when the prompt was + cancelled by the user, or None. default: The default value to display. """ q = Question() q.text = message q.mode = mode q.default = default - q.answered.connect(lambda: handler(q.answer)) - instance().question.emit(q, True) + q.answered.connect(handler) + if cancelled_handler is not None: + q.cancelled.connect(cancelled_handler) + instance().question.emit(q, False) def confirm_action(message, yes_action, no_action=None, default=None): diff --git a/qutebrowser/utils/misc.py b/qutebrowser/utils/misc.py index a7d2c5be2..19121cd4c 100644 --- a/qutebrowser/utils/misc.py +++ b/qutebrowser/utils/misc.py @@ -33,6 +33,7 @@ from functools import reduce from distutils.version import StrictVersion as Version from PyQt5.QtCore import QCoreApplication, QStandardPaths, qVersion +from PyQt5.QtGui import QColor from pkg_resources import resource_string import qutebrowser @@ -284,3 +285,102 @@ def get_qt_args(namespace): argv.append('-' + argname[3:]) argv.append(val[0]) return argv + + +def _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3, percent): + """Get a color which is percent% interpolated between start and end. + + Args: + a_c1, a_c2, a_c3: Start color components (R, G, B / H, S, V / H, S, L) + b_c1, b_c2, b_c3: End color components (R, G, B / H, S, V / H, S, L) + percent: Percentage to interpolate, 0-100. + 0: Start color will be returned. + 100: End color will be returned. + + Return: + A (c1, c2, c3) tuple with the interpolated color components. + + Raise: + ValueError if the percentage was invalid. + """ + if not 0 <= percent <= 100: + raise ValueError("percent needs to be between 0 and 100!") + out_c1 = round(a_c1 + (b_c1 - a_c1) * percent / 100) + out_c2 = round(a_c2 + (b_c2 - a_c2) * percent / 100) + out_c3 = round(a_c3 + (b_c3 - a_c3) * percent / 100) + return (out_c1, out_c2, out_c3) + + +def interpolate_color(start, end, percent, colorspace=QColor.Rgb): + """Get an interpolated color value. + + Args: + start: The start color. + end: The end color. + percent: Which value to get (0 - 100) + colorspace: The desired interpolation colorsystem, + QColor::{Rgb,Hsv,Hsl} (from QColor::Spec enum) + + Return: + The interpolated QColor, with the same spec as the given start color. + + Raise: + ValueError if invalid parameters are passed. + """ + if not start.isValid(): + raise ValueError("Invalid start color") + if not end.isValid(): + raise ValueError("Invalid end color") + out = QColor() + if colorspace == QColor.Rgb: + a_c1, a_c2, a_c3, _alpha = start.getRgb() + b_c1, b_c2, b_c3, _alpha = end.getRgb() + components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3, + percent) + out.setRgb(*components) + elif colorspace == QColor.Hsv: + a_c1, a_c2, a_c3, _alpha = start.getHsv() + b_c1, b_c2, b_c3, _alpha = end.getHsv() + components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3, + percent) + out.setHsv(*components) + elif colorspace == QColor.Hsl: + a_c1, a_c2, a_c3, _alpha = start.getHsl() + b_c1, b_c2, b_c3, _alpha = end.getHsl() + components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3, + percent) + out.setHsl(*components) + else: + raise ValueError("Invalid colorspace!") + return out.convertTo(start.spec()) + + +def format_seconds(total_seconds): + """Format a count of seconds to get a [H:]M:SS string.""" + prefix = '-' if total_seconds < 0 else '' + hours, rem = divmod(abs(round(total_seconds)), 3600) + minutes, seconds = divmod(rem, 60) + chunks = [] + if hours: + chunks.append(str(hours)) + min_format = '{:02}' + else: + min_format = '{}' + chunks.append(min_format.format(minutes)) + chunks.append('{:02}'.format(seconds)) + return prefix + ':'.join(chunks) + + +def format_size(size, base=1024, suffix=''): + """Format a byte size so it's human readable. + + Inspired by http://stackoverflow.com/q/1094841 + """ + prefixes = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] + if size is None: + return '?.??' + suffix + for p in prefixes: + if -base < size < base: + return '{:.02f}{}{}'.format(size, p, suffix) + size /= base + return '{:.02f}{}{}'.format(size, prefixes[-1], suffix) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 56090db50..dba32e03e 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -250,18 +250,25 @@ class Question(QObject): text: The prompt text to display to the user. user: The value the user entered as username. answer: The value the user entered (as password for user_pwd). + is_aborted: Whether the question was aborted. Signals: answered: Emitted when the question has been answered by the user. This is emitted from qutebrowser.widgets.statusbar._prompt so it can be emitted after the mode is left. + arg: The answer to the question. + cancelled: Emitted when the question has been cancelled by the user. + aborted: Emitted when the question was aborted programatically. + In this case, cancelled is not emitted. answered_yes: Convienience signal emitted when a yesno question was answered with yes. answered_no: Convienience signal emitted when a yesno question was answered with no. """ - answered = pyqtSignal() + answered = pyqtSignal(str) + cancelled = pyqtSignal() + aborted = pyqtSignal() answered_yes = pyqtSignal() answered_no = pyqtSignal() @@ -272,3 +279,13 @@ class Question(QObject): self.text = None self.user = None self.answer = None + self.is_aborted = False + + def abort(self): + """Abort the question. + + Emit: + aborted: Always emitted. + """ + self.is_aborted = True + self.aborted.emit() diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py new file mode 100644 index 000000000..cd401b053 --- /dev/null +++ b/qutebrowser/widgets/downloads.py @@ -0,0 +1,86 @@ +# Copyright 2014 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 . + +"""The ListView to display downloads in.""" + +from PyQt5.QtCore import pyqtSlot, QSize, Qt +from PyQt5.QtWidgets import QListView, QSizePolicy, QMenu + +from qutebrowser.models.downloadmodel import DownloadModel, Role +from qutebrowser.config.style import set_register_stylesheet + + +class DownloadView(QListView): + + """QListView which shows currently running downloads as a bar. + + Attributes: + _menu: The QMenu which is currently displayed. + _model: The currently set model. + """ + + STYLESHEET = """ + QListView {{ + {color[downloads.bg.bar]} + {font[downloads]} + }} + + QListView::item {{ + padding-right: 2px; + }} + """ + + def __init__(self, parent=None): + super().__init__(parent) + set_register_stylesheet(self) + self.setResizeMode(QListView.Adjust) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + self.setFlow(QListView.LeftToRight) + self._menu = None + self._model = DownloadModel() + self._model.rowsInserted.connect(self.updateGeometry) + self._model.rowsRemoved.connect(self.updateGeometry) + self.setModel(self._model) + self.setWrapping(True) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_context_menu) + + @pyqtSlot('QPoint') + def show_context_menu(self, point): + """Show the context menu.""" + index = self.indexAt(point) + if not index.isValid(): + return + item = self.model().data(index, Role.item) + self._menu = QMenu() + cancel = self._menu.addAction("Cancel") + cancel.triggered.connect(item.cancel) + self._menu.popup(self.viewport().mapToGlobal(point)) + + def minimumSizeHint(self): + """Override minimumSizeHint so the size is correct in a layout.""" + return self.sizeHint() + + def sizeHint(self): + """Return sizeHint based on the view contents.""" + idx = self.model().last_index() + height = self.visualRect(idx).bottom() + if height != -1: + return QSize(0, height + 2) + else: + return QSize(0, 0) diff --git a/qutebrowser/widgets/mainwindow.py b/qutebrowser/widgets/mainwindow.py index e6b52f38c..8a9023b1c 100644 --- a/qutebrowser/widgets/mainwindow.py +++ b/qutebrowser/widgets/mainwindow.py @@ -30,6 +30,7 @@ import qutebrowser.utils.message as message from qutebrowser.widgets.statusbar.bar import StatusBar from qutebrowser.widgets.tabbedbrowser import TabbedBrowser from qutebrowser.widgets.completion import CompletionView +from qutebrowser.widgets.downloads import DownloadView from qutebrowser.utils.usertypes import PromptMode @@ -43,6 +44,7 @@ class MainWindow(QWidget): Attributes: tabs: The TabbedBrowser widget. status: The StatusBar widget. + downloadview: The DownloadView widget. _vbox: The main QVBoxLayout. """ @@ -68,6 +70,10 @@ class MainWindow(QWidget): self._vbox.setContentsMargins(0, 0, 0, 0) self._vbox.setSpacing(0) + self.downloadview = DownloadView() + self._vbox.addWidget(self.downloadview) + self.downloadview.show() + self.tabs = TabbedBrowser() self._vbox.addWidget(self.tabs) @@ -130,6 +136,7 @@ class MainWindow(QWidget): """ super().resizeEvent(e) self.resize_completion() + self.downloadview.updateGeometry() def closeEvent(self, e): """Override closeEvent to display a confirmation if needed.""" diff --git a/qutebrowser/widgets/statusbar/prompt.py b/qutebrowser/widgets/statusbar/prompt.py index 3da89e94e..acc1cf52d 100644 --- a/qutebrowser/widgets/statusbar/prompt.py +++ b/qutebrowser/widgets/statusbar/prompt.py @@ -41,12 +41,10 @@ class Prompt(QWidget): Signals: show_prompt: Emitted when the prompt widget wants to be shown. hide_prompt: Emitted when the prompt widget wants to be hidden. - cancelled: Emitted when the prompt was cancelled by the user. """ show_prompt = pyqtSignal() hide_prompt = pyqtSignal() - cancelled = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -65,19 +63,14 @@ class Prompt(QWidget): self._hbox.addWidget(self._input) def on_mode_left(self, mode): - """Clear and reset input when the mode was left. - - Emit: - cancelled: Emitted when the mode was forcibly left by the user - without answering the question. - """ + """Clear and reset input when the mode was left.""" if mode in ('prompt', 'yesno'): self._txt.setText('') self._input.clear() self._input.setEchoMode(QLineEdit.Normal) self.hide_prompt.emit() - if self.question.answer is None: - self.cancelled.emit() + if self.question.answer is None and not self.question.is_aborted: + self.question.cancelled.emit() @cmdutils.register(instance='mainwindow.status.prompt', hide=True, modes=['prompt']) @@ -100,22 +93,22 @@ class Prompt(QWidget): self.question.answer = (self.question.user, password) modeman.leave('prompt', 'prompt accept') self.hide_prompt.emit() - self.question.answered.emit() + self.question.answered.emit(self.question.answer) elif self.question.mode == PromptMode.text: # User just entered text. self.question.answer = self._input.text() modeman.leave('prompt', 'prompt accept') - self.question.answered.emit() + self.question.answered.emit(self.question.answer) elif self.question.mode == PromptMode.yesno: # User wants to accept the default of a yes/no question. self.question.answer = self.question.default modeman.leave('yesno', 'yesno accept') - self.question.answered.emit() + self.question.answered.emit(self.question.answer) elif self.question.mode == PromptMode.alert: # User acknowledged an alert self.question.answer = None modeman.leave('prompt', 'alert accept') - self.question.answered.emit() + self.question.answered.emit(self.question.answer) else: raise ValueError("Invalid question mode!") @@ -128,7 +121,7 @@ class Prompt(QWidget): return self.question.answer = True modeman.leave('yesno', 'yesno accept') - self.question.answered.emit() + self.question.answered.emit(self.question.answer) self.question.answered_yes.emit() @cmdutils.register(instance='mainwindow.status.prompt', hide=True, @@ -140,7 +133,7 @@ class Prompt(QWidget): return self.question.answer = False modeman.leave('yesno', 'prompt accept') - self.question.answered.emit() + self.question.answered.emit(self.question.answer) self.question.answered_no.emit() def display(self): @@ -180,6 +173,7 @@ class Prompt(QWidget): raise ValueError("Invalid prompt mode!") self._input.setFocus() self.show_prompt.emit() + q.aborted.connect(lambda: modeman.maybe_leave(mode, 'aborted')) modeman.enter(mode, 'question asked') @pyqtSlot(Question, bool) @@ -206,6 +200,7 @@ class Prompt(QWidget): The answer to the question. No, it's not always 42. """ self.question.answered.connect(self.loop.quit) - self.cancelled.connect(self.loop.quit) + self.question.cancelled.connect(self.loop.quit) + self.question.aborted.connect(self.loop.quit) self.loop.exec_() return self.question.answer diff --git a/qutebrowser/widgets/tabbedbrowser.py b/qutebrowser/widgets/tabbedbrowser.py index 7bec5ebd4..8fc88ff42 100644 --- a/qutebrowser/widgets/tabbedbrowser.py +++ b/qutebrowser/widgets/tabbedbrowser.py @@ -72,6 +72,8 @@ class TabbedBrowser(TabWidget): resized: Emitted when the browser window has resized, so the completion widget can adjust its size to it. arg: The new size. + start_download: Emitted when any tab wants to start downloading + something. """ cur_progress = pyqtSignal(int) @@ -82,6 +84,7 @@ class TabbedBrowser(TabWidget): cur_link_hovered = pyqtSignal(str, str, str) cur_scroll_perc_changed = pyqtSignal(int, int) cur_load_status_changed = pyqtSignal(str) + start_download = pyqtSignal('QNetworkReply*') hint_strings_updated = pyqtSignal(list) shutdown_complete = pyqtSignal() quit = pyqtSignal() @@ -150,6 +153,9 @@ class TabbedBrowser(TabWidget): # hintmanager tab.hintmanager.hint_strings_updated.connect(self.hint_strings_updated) tab.hintmanager.openurl.connect(self.cmd.openurl) + # downloads + tab.page().unsupportedContent.connect(self.start_download) + tab.page().start_download.connect(self.start_download) # misc tab.titleChanged.connect(self.on_title_changed) tab.iconChanged.connect(self.on_icon_changed)