From 7dd5b1b94edcbc8cb5788b61f7460b9833afc2eb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 10 Jun 2014 22:11:17 +0200 Subject: [PATCH 01/48] First debugging implementation of downloads --- qutebrowser/app.py | 5 ++ qutebrowser/browser/downloads.py | 88 ++++++++++++++++++++++++++++ qutebrowser/browser/webpage.py | 19 +++++- qutebrowser/utils/log.py | 1 + qutebrowser/widgets/tabbedbrowser.py | 6 ++ 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 qutebrowser/browser/downloads.py diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 39f1a85e7..845a083c9 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -53,6 +53,7 @@ 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.utils.message import MessageBridge from qutebrowser.utils.misc import (get_standard_dir, actute_warning, get_qt_args) @@ -132,6 +133,7 @@ class Application(QApplication): self.networkmanager = NetworkManager(self.cookiejar) self.commandmanager = CommandManager() self.searchmanager = SearchManager() + self.downloadmanager = DownloadManager() self.mainwindow = MainWindow() self.modeman.mainwindow = self.mainwindow @@ -386,6 +388,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..4877ce526 --- /dev/null +++ b/qutebrowser/browser/downloads.py @@ -0,0 +1,88 @@ +# 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.path + +from PyQt5.QtCore import pyqtSlot, QObject + +from qutebrowser.utils.log import downloads as logger + + +class DownloadManager(QObject): + + """Manager for running downloads.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.downloads = [] + + 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. + """ + filename = self._get_filename(reply) + logger.debug("fetch: {} -> {}".format(reply.url(), filename)) + reply.downloadProgress.connect(self.on_download_progress) + reply.readyRead.connect(self.on_ready_read) + reply.finished.connect(self.on_finished) + + @pyqtSlot(int, int) + def on_download_progress(self, done, total): + if total == -1: + perc = '???' + else: + perc = 100 * done / total + logger.debug("{}% done".format(perc)) + + @pyqtSlot() + def on_ready_read(self): + logger.debug("readyread") + self.sender().readAll() + + @pyqtSlot() + def on_finished(self): + logger.debug("finished") diff --git a/qutebrowser/browser/webpage.py b/qutebrowser/browser/webpage.py index 0ff2ebb01..8b395e836 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 = { @@ -50,6 +55,7 @@ class BrowserPage(QWebPage): self.setNetworkAccessManager( QCoreApplication.instance().networkmanager) 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 +124,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/utils/log.py b/qutebrowser/utils/log.py index 2466be5ca..b6ae5d007 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -66,6 +66,7 @@ init = getLogger('init') signals = getLogger('signals') hints = getLogger('hints') keyboard = getLogger('keyboard') +downloads = getLogger('downloads') js = getLogger('js') qt = getLogger('qt') 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) From 80e2259df3c1202026266d103d8f15e97533e878 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 11 Jun 2014 17:27:39 +0200 Subject: [PATCH 02/48] Add DownloadItem class --- qutebrowser/browser/downloads.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 4877ce526..aa21fb5c6 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -24,6 +24,30 @@ from PyQt5.QtCore import pyqtSlot, QObject from qutebrowser.utils.log import downloads as logger +class DownloadItem(QObject): + + """A single download currently running. + + Attributes: + reply: The QNetworkReply associated with this download. + percentage: How many percent were downloaded successfully. + """ + + def __init__(self, reply, parent=None): + super().__init__(parent) + self.reply = reply + self.percentage = None + + @pyqtSlot(int, int) + def on_download_progress(self, done, total): + if total == -1: + perc = -1 + else: + perc = 100 * done / total + logger.debug("{}% done".format(perc)) + self.percentage = perc + + class DownloadManager(QObject): """Manager for running downloads.""" From 96891f62410869f8512a5d1883b9d778e17d71f1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 11 Jun 2014 21:55:23 +0200 Subject: [PATCH 03/48] First working download draft --- qutebrowser/browser/downloads.py | 114 ++++++++++++++++++++++--------- qutebrowser/config/configdata.py | 5 ++ qutebrowser/config/conftypes.py | 28 +++++++- 3 files changed, 115 insertions(+), 32 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index aa21fb5c6..303210fa2 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -19,33 +19,98 @@ import os.path -from PyQt5.QtCore import pyqtSlot, QObject +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer +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 class DownloadItem(QObject): """A single download currently running. + Class attributes: + REFRESH_INTERVAL: How often to refresh the speed, in msec. + Attributes: reply: The QNetworkReply associated with this download. percentage: How many percent were downloaded successfully. + bytes_done: How many bytes there are already downloaded. + bytes_total: The total count of bytes. + speed: The current download speed, in bytes per second. + fileobj: The file object to download the file to. + _last_done: The count of bytes which where downloaded when calculating + the speed the last time. """ - def __init__(self, reply, parent=None): + REFRESH_INTERVAL = 1000 + + def __init__(self, reply, filename, parent=None): + """Constructor. + + Args: + reply: The QNetworkReply to download. + filename: The full filename to save the download to. + """ super().__init__(parent) self.reply = reply - self.percentage = None + self.bytes_done = None + self.bytes_total = None + self.speed = None + self._last_done = None + # FIXME exceptions + self.fileobj = open(filename, 'wb') + reply.downloadProgress.connect(self.on_download_progress) + reply.finished.connect(self.on_finished) + self.timer = QTimer() + self.timer.timeout.connect(self.update_speed) + self.timer.setInterval(self.REFRESH_INTERVAL) + self.timer.start() + + @property + def percentage(self): + if self.bytes_total == -1: + return -1 + elif self.bytes_total == 0: + return 0 + elif self.bytes_done is None or self.bytes_total is None: + return None + else: + return 100 * self.bytes_done / self.bytes_total @pyqtSlot(int, int) - def on_download_progress(self, done, total): - if total == -1: - perc = -1 + def on_download_progress(self, bytes_done, bytes_total): + self.bytes_done = bytes_done + self.bytes_total = bytes_total + + @pyqtSlot() + def on_finished(self): + """Clean up when the download was finished.""" + self.bytes_done = self.bytes_total + self.timer.stop() + self.fileobj.write(self.reply.readAll()) + self.fileobj.close() + self.reply.close() + self.reply.deleteLater() + + @pyqtSlot() + def on_ready_read(self): + """Read available data and save file when ready to read.""" + # FIXME exceptions + self.fileobj.write(self.reply.readAll()) + + @pyqtSlot() + def update_speed(self): + """Recalculate the current download speed.""" + if self._last_done is None: + delta = self.bytes_done else: - perc = 100 * done / total - logger.debug("{}% done".format(perc)) - self.percentage = perc + delta = self.bytes_done - self._last_done + self.speed = delta / self.REFRESH_INTERVAL / 1000 + logger.debug("Download speed: {} bytes/sec".format(self.speed)) + self._last_done = self.bytes_done class DownloadManager(QObject): @@ -88,25 +153,12 @@ class DownloadManager(QObject): Args: reply: The QNetworkReply to download. """ - filename = self._get_filename(reply) - logger.debug("fetch: {} -> {}".format(reply.url(), filename)) - reply.downloadProgress.connect(self.on_download_progress) - reply.readyRead.connect(self.on_ready_read) - reply.finished.connect(self.on_finished) - - @pyqtSlot(int, int) - def on_download_progress(self, done, total): - if total == -1: - perc = '???' - else: - perc = 100 * done / total - logger.debug("{}% done".format(perc)) - - @pyqtSlot() - def on_ready_read(self): - logger.debug("readyread") - self.sender().readAll() - - @pyqtSlot() - def on_finished(self): - logger.debug("finished") + suggested_filename = self._get_filename(reply) + download_location = config.get('storage', 'download-directory') + suggested_filename = os.path.join(download_location, + suggested_filename) + logger.debug("fetch: {} -> {}".format(reply.url(), suggested_filename)) + filename = message.modular_question("Save file to:", PromptMode.text, + suggested_filename) + if filename is not None: + self.downloads.append(DownloadItem(reply, filename)) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 42d6d4c13..bbc2834ff 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']), ''), diff --git a/qutebrowser/config/conftypes.py b/qutebrowser/config/conftypes.py index 2095db8ac..a8c0b215f 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): @@ -530,6 +531,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. From c61289cedcf5f5630b236f895c6349e114824da5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 11 Jun 2014 21:58:06 +0200 Subject: [PATCH 04/48] Fix lint --- qutebrowser/browser/downloads.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 303210fa2..4ad494b98 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -19,7 +19,7 @@ import os.path -from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer +from PyQt5.QtCore import pyqtSlot, QObject, QTimer import qutebrowser.config.config as config import qutebrowser.utils.message as message @@ -71,6 +71,7 @@ class DownloadItem(QObject): @property def percentage(self): + """Property to get the current download percentage.""" if self.bytes_total == -1: return -1 elif self.bytes_total == 0: @@ -82,6 +83,12 @@ class DownloadItem(QObject): @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. + """ self.bytes_done = bytes_done self.bytes_total = bytes_total @@ -125,7 +132,8 @@ class DownloadManager(QObject): """Get a suitable filename to download a file to. Args: - reply: The QNetworkReply to get a filename for.""" + reply: The QNetworkReply to get a filename for. + """ filename = None # First check if the Content-Disposition header has a filename # attribute. From 788302890fc57c1e80035e84f1017ddb47dcc492 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 11 Jun 2014 22:33:40 +0200 Subject: [PATCH 05/48] Add signals to DownloadItem --- qutebrowser/browser/downloads.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 4ad494b98..910b3bff5 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -19,7 +19,7 @@ import os.path -from PyQt5.QtCore import pyqtSlot, QObject, QTimer +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer import qutebrowser.config.config as config import qutebrowser.utils.message as message @@ -43,9 +43,18 @@ class DownloadItem(QObject): fileobj: The file object to download the file to. _last_done: The count of bytes which where downloaded when calculating the speed the last time. + _last_percentage: The remembered percentage for percentage_changed. + + Signals: + speed_changed: The download speed changed. + arg: The speed in bytes/s + percentage_changed: The download percentage changed. + arg: The new percentage, -1 if unknown. """ REFRESH_INTERVAL = 1000 + speed_changed = pyqtSignal(float) + percentage_changed = pyqtSignal(int) def __init__(self, reply, filename, parent=None): """Constructor. @@ -60,6 +69,7 @@ class DownloadItem(QObject): self.bytes_total = None self.speed = None self._last_done = None + self._last_percentage = None # FIXME exceptions self.fileobj = open(filename, 'wb') reply.downloadProgress.connect(self.on_download_progress) @@ -91,6 +101,10 @@ class DownloadItem(QObject): """ self.bytes_done = bytes_done self.bytes_total = bytes_total + perc = round(self.percentage) + if perc != self._last_percentage: + self.percentage_changed.emit(perc) + self._last_percentage = perc @pyqtSlot() def on_finished(self): @@ -118,6 +132,7 @@ class DownloadItem(QObject): self.speed = delta / self.REFRESH_INTERVAL / 1000 logger.debug("Download speed: {} bytes/sec".format(self.speed)) self._last_done = self.bytes_done + self.speed_changed.emit(self.speed) class DownloadManager(QObject): From 35d35d31da8f2de2f4356868997cea7d31003694 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 11 Jun 2014 22:35:02 +0200 Subject: [PATCH 06/48] Add some logging --- qutebrowser/browser/downloads.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 910b3bff5..92c56d895 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -103,6 +103,7 @@ class DownloadItem(QObject): self.bytes_total = bytes_total perc = round(self.percentage) if perc != self._last_percentage: + logger.debug("{}% downloaded".format(perc)) self.percentage_changed.emit(perc) self._last_percentage = perc @@ -115,6 +116,7 @@ class DownloadItem(QObject): self.fileobj.close() self.reply.close() self.reply.deleteLater() + logger.debug("Download finished") @pyqtSlot() def on_ready_read(self): From ace59e6f01669aec05a06dadea02de6b1346e731 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 11 Jun 2014 22:40:28 +0200 Subject: [PATCH 07/48] Fix speed calculation and do it more often --- qutebrowser/browser/downloads.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 92c56d895..82edc315b 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -52,7 +52,7 @@ class DownloadItem(QObject): arg: The new percentage, -1 if unknown. """ - REFRESH_INTERVAL = 1000 + REFRESH_INTERVAL = 200 speed_changed = pyqtSignal(float) percentage_changed = pyqtSignal(int) @@ -131,7 +131,7 @@ class DownloadItem(QObject): delta = self.bytes_done else: delta = self.bytes_done - self._last_done - self.speed = delta / self.REFRESH_INTERVAL / 1000 + self.speed = delta * 1000 / self.REFRESH_INTERVAL logger.debug("Download speed: {} bytes/sec".format(self.speed)) self._last_done = self.bytes_done self.speed_changed.emit(self.speed) From 2114f0cd73005d0af8f63e4b267ec09d2d31829c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 08:02:44 +0200 Subject: [PATCH 08/48] Start adding download model/view --- qutebrowser/app.py | 2 ++ qutebrowser/browser/downloads.py | 32 +++++++++++++++-- qutebrowser/models/downloadmodel.py | 54 +++++++++++++++++++++++++++++ qutebrowser/widgets/downloads.py | 31 +++++++++++++++++ qutebrowser/widgets/mainwindow.py | 6 ++++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 qutebrowser/models/downloadmodel.py create mode 100644 qutebrowser/widgets/downloads.py diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 845a083c9..264cc4c3f 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -54,6 +54,7 @@ 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) @@ -134,6 +135,7 @@ class Application(QApplication): self.commandmanager = CommandManager() self.searchmanager = SearchManager() self.downloadmanager = DownloadManager() + self.downloadmodel = DownloadModel(self.downloadmanager) self.mainwindow = MainWindow() self.modeman.mainwindow = self.mainwindow diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 82edc315b..833b7d65b 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -50,11 +50,13 @@ class DownloadItem(QObject): arg: The speed in bytes/s percentage_changed: The download percentage changed. arg: The new percentage, -1 if unknown. + finished: The download was finished. """ REFRESH_INTERVAL = 200 speed_changed = pyqtSignal(float) percentage_changed = pyqtSignal(int) + finished = pyqtSignal() def __init__(self, reply, filename, parent=None): """Constructor. @@ -74,6 +76,7 @@ class DownloadItem(QObject): self.fileobj = open(filename, 'wb') reply.downloadProgress.connect(self.on_download_progress) reply.finished.connect(self.on_finished) + reply.finished.connect(self.finished) self.timer = QTimer() self.timer.timeout.connect(self.update_speed) self.timer.setInterval(self.REFRESH_INTERVAL) @@ -139,7 +142,21 @@ class DownloadItem(QObject): class DownloadManager(QObject): - """Manager for running downloads.""" + """Manager for running downloads. + + 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. + """ + + download_about_to_be_added = pyqtSignal(int) + download_added = pyqtSignal() + download_about_to_be_finished = pyqtSignal(int) + download_finished = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -186,4 +203,15 @@ class DownloadManager(QObject): filename = message.modular_question("Save file to:", PromptMode.text, suggested_filename) if filename is not None: - self.downloads.append(DownloadItem(reply, filename)) + download = DownloadItem(reply, filename) + download.finished.connect(self.on_finished) + self.download_about_to_be_added.emit(len(self.downloads) + 1) + self.downloads.append(download) + self.download_added.emit() + + @pyqtSlot() + def on_finished(self): + idx = self.downloads.index(self.sender()) + self.download_about_to_be_finished.emit(idx) + del self.downloads[idx] + self.download_finished.emit() diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py new file mode 100644 index 000000000..c40f60e26 --- /dev/null +++ b/qutebrowser/models/downloadmodel.py @@ -0,0 +1,54 @@ +# 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 Qt, QVariant, QAbstractListModel +from PyQt5.QtWidgets import QApplication + + +class DownloadModel(QAbstractListModel): + + def __init__(self, parent=None): + super().__init__(parent) + self.downloadmanager = QApplication.instance().downloadmanager + + def headerData(self, section, orientation, role): + if (section == 0 and orientation == Qt.Horizontal and + role == Qt.DisplayRole): + return "Downloads" + else: + return "" + + def data(self, index, role): + if not index.isValid(): + return QVariant() + elif role != Qt.DisplayRole: + return QVariant() + elif index.parent().isValid() or index.column() != 0: + return QVariant() + try: + item = self.downloadmanager.downloads[index.row()] + except IndexError: + return QVariant() + return str(item.percentage) # FIXME + + def rowCount(self, parent): + if parent.isValid(): + # We don't have children + return 0 + return len(self.downloadmanager.downloads) diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py new file mode 100644 index 000000000..52e80fe3b --- /dev/null +++ b/qutebrowser/widgets/downloads.py @@ -0,0 +1,31 @@ +# 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.QtWidgets import QListView + +from qutebrowser.models.downloadmodel import DownloadModel + + +class DownloadView(QListView): + + def __init__(self, parent=None): + super().__init__(parent) + self.setFlow(QListView.LeftToRight) + self._model = DownloadModel() + self.setModel(self._model) diff --git a/qutebrowser/widgets/mainwindow.py b/qutebrowser/widgets/mainwindow.py index e6b52f38c..f37b75ffd 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. """ @@ -71,6 +73,10 @@ class MainWindow(QWidget): self.tabs = TabbedBrowser() self._vbox.addWidget(self.tabs) + self.downloadview = DownloadView() + self._vbox.addWidget(self.downloadview) + self.downloadview.show() + self.completion = CompletionView(self) self.completion.resize_completion.connect(self.resize_completion) From a0d4429a32a39f6f30893f9ab50b4afbc527a1a3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:17:49 +0200 Subject: [PATCH 09/48] Log download errors --- qutebrowser/browser/downloads.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 833b7d65b..1a3571e76 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -77,6 +77,7 @@ class DownloadItem(QObject): reply.downloadProgress.connect(self.on_download_progress) reply.finished.connect(self.on_finished) reply.finished.connect(self.finished) + reply.error.connect(self.on_error) self.timer = QTimer() self.timer.timeout.connect(self.update_speed) self.timer.setInterval(self.REFRESH_INTERVAL) @@ -139,6 +140,10 @@ class DownloadItem(QObject): self._last_done = self.bytes_done self.speed_changed.emit(self.speed) + @pyqtSlot(int) + def on_error(self, code): + logger.debug("Error {} in download".format(code)) + class DownloadManager(QObject): From 22a0639825c7f62ecdb62b2fefc566acee45ebcd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:18:02 +0200 Subject: [PATCH 10/48] Connect readyRead signal properly --- qutebrowser/browser/downloads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 1a3571e76..932a47210 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -78,6 +78,7 @@ class DownloadItem(QObject): reply.finished.connect(self.on_finished) reply.finished.connect(self.finished) reply.error.connect(self.on_error) + reply.readyRead.connect(self.on_ready_read) self.timer = QTimer() self.timer.timeout.connect(self.update_speed) self.timer.setInterval(self.REFRESH_INTERVAL) From 905eb9056dc768191f41f04da68677b241a4e2d0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:18:25 +0200 Subject: [PATCH 11/48] Fix speed calculation if downloadProgress hasn't been called yet. --- qutebrowser/browser/downloads.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 932a47210..e77c888fc 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -133,10 +133,14 @@ class DownloadItem(QObject): def update_speed(self): """Recalculate the current download speed.""" if self._last_done is None: - delta = self.bytes_done + if self.bytes_done is None: + self.speed = 0 + else: + delta = self.bytes_done + self.speed = delta * 1000 / self.REFRESH_INTERVAL else: delta = self.bytes_done - self._last_done - self.speed = delta * 1000 / self.REFRESH_INTERVAL + self.speed = delta * 1000 / self.REFRESH_INTERVAL logger.debug("Download speed: {} bytes/sec".format(self.speed)) self._last_done = self.bytes_done self.speed_changed.emit(self.speed) From db5586544945847be0bf8ae994e28aeb6c8da6a5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:19:03 +0200 Subject: [PATCH 12/48] Add data_changed signal to downloadmanager. --- qutebrowser/browser/downloads.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index e77c888fc..4f73b705e 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -161,12 +161,15 @@ class DownloadManager(QObject): 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) @@ -215,6 +218,8 @@ class DownloadManager(QObject): if filename is not None: download = DownloadItem(reply, filename) download.finished.connect(self.on_finished) + download.percentage_changed.connect(self.on_data_changed) + download.speed_changed.connect(self.on_data_changed) self.download_about_to_be_added.emit(len(self.downloads) + 1) self.downloads.append(download) self.download_added.emit() @@ -225,3 +230,8 @@ class DownloadManager(QObject): self.download_about_to_be_finished.emit(idx) del self.downloads[idx] self.download_finished.emit() + + @pyqtSlot() + def on_data_changed(self): + idx = self.downloads.index(self.sender()) + self.data_changed.emit(idx) From dcd05cae146b1bd30ba8d2e3b8771d29e8bf092b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:19:45 +0200 Subject: [PATCH 13/48] Move downloadview before browser --- qutebrowser/widgets/mainwindow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/qutebrowser/widgets/mainwindow.py b/qutebrowser/widgets/mainwindow.py index f37b75ffd..6a21b34ae 100644 --- a/qutebrowser/widgets/mainwindow.py +++ b/qutebrowser/widgets/mainwindow.py @@ -70,13 +70,13 @@ class MainWindow(QWidget): self._vbox.setContentsMargins(0, 0, 0, 0) self._vbox.setSpacing(0) - self.tabs = TabbedBrowser() - self._vbox.addWidget(self.tabs) - self.downloadview = DownloadView() self._vbox.addWidget(self.downloadview) self.downloadview.show() + self.tabs = TabbedBrowser() + self._vbox.addWidget(self.tabs) + self.completion = CompletionView(self) self.completion.resize_completion.connect(self.resize_completion) From 8d9372045d4dcd5d7002e5c09914c74728a53f8e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:20:10 +0200 Subject: [PATCH 14/48] Connect downloadmanager signals to model properly --- qutebrowser/models/downloadmodel.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index c40f60e26..ba535f7eb 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -17,7 +17,8 @@ """Glue code for qutebrowser.{browser,widgets}.download.""" -from PyQt5.QtCore import Qt, QVariant, QAbstractListModel +from PyQt5.QtCore import (pyqtSlot, Qt, QVariant, QAbstractListModel, + QModelIndex) from PyQt5.QtWidgets import QApplication @@ -26,6 +27,18 @@ class DownloadModel(QAbstractListModel): 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): + model_idx = self.index(idx, 0) + self.dataChanged.emit(model_idx, model_idx) def headerData(self, section, orientation, role): if (section == 0 and orientation == Qt.Horizontal and From 3150a88a3b427f2f92a6ecf334effe5c47d73783 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:20:27 +0200 Subject: [PATCH 15/48] Fix download model output if percentage is None --- qutebrowser/models/downloadmodel.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index ba535f7eb..29fcef5e8 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -58,7 +58,11 @@ class DownloadModel(QAbstractListModel): item = self.downloadmanager.downloads[index.row()] except IndexError: return QVariant() - return str(item.percentage) # FIXME + perc = item.percentage + if perc is None: + return QVariant() + else: + return str(round(perc)) # FIXME def rowCount(self, parent): if parent.isValid(): From 704be222d56c357adf1869decf0c0071c4ee0877 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 10:20:42 +0200 Subject: [PATCH 16/48] Set DownloadView size policy --- qutebrowser/widgets/downloads.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index 52e80fe3b..241784679 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -17,7 +17,7 @@ """The ListView to display downloads in.""" -from PyQt5.QtWidgets import QListView +from PyQt5.QtWidgets import QListView, QSizePolicy from qutebrowser.models.downloadmodel import DownloadModel @@ -26,6 +26,7 @@ class DownloadView(QListView): def __init__(self, parent=None): super().__init__(parent) + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.setFlow(QListView.LeftToRight) self._model = DownloadModel() self.setModel(self._model) From 07138909805a47029bb160c5c9a68f316afb133a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 13:05:43 +0200 Subject: [PATCH 17/48] Fix DownloadView sizing --- qutebrowser/widgets/downloads.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index 241784679..23ca51879 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -17,6 +17,7 @@ """The ListView to display downloads in.""" +from PyQt5.QtCore import QSize from PyQt5.QtWidgets import QListView, QSizePolicy from qutebrowser.models.downloadmodel import DownloadModel @@ -29,4 +30,17 @@ class DownloadView(QListView): self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.setFlow(QListView.LeftToRight) self._model = DownloadModel() + self._model.rowsInserted.connect(self.updateGeometry) + self._model.rowsRemoved.connect(self.updateGeometry) self.setModel(self._model) + self.setWrapping(True) + + def minimumSizeHint(self): + return self.sizeHint() + + def sizeHint(self): + height = self.sizeHintForRow(0) + if height != -1: + return QSize(0, height + 2) + else: + return QSize(0, 0) From dc0b0250550890da037e3449f81508e82b385fbd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 13:17:45 +0200 Subject: [PATCH 18/48] Forward unsupported content properly --- qutebrowser/browser/webpage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/browser/webpage.py b/qutebrowser/browser/webpage.py index 8b395e836..336e47ebd 100644 --- a/qutebrowser/browser/webpage.py +++ b/qutebrowser/browser/webpage.py @@ -54,6 +54,7 @@ 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) From c91dced99fb30726b3ce4ba5a365246304e61384 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 17:49:36 +0200 Subject: [PATCH 19/48] Make it possible to cancel a message.question --- qutebrowser/utils/message.py | 6 +++++- qutebrowser/utils/usertypes.py | 2 ++ qutebrowser/widgets/statusbar/prompt.py | 13 +++---------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index f3d3ee786..a9a1f519b 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -88,13 +88,15 @@ 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() @@ -102,6 +104,8 @@ def question(message, mode, handler, default=None): q.mode = mode q.default = default q.answered.connect(lambda: handler(q.answer)) + if cancelled_handler is not None: + q.cancelled.connect(cancelled_handler) instance().question.emit(q, True) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 56090db50..f90947641 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -255,6 +255,7 @@ class Question(QObject): 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. + cancelled: Emitted when the question has been cancelled by the user. answered_yes: Convienience signal emitted when a yesno question was answered with yes. answered_no: Convienience signal emitted when a yesno question was @@ -262,6 +263,7 @@ class Question(QObject): """ answered = pyqtSignal() + cancelled = pyqtSignal() answered_yes = pyqtSignal() answered_no = pyqtSignal() diff --git a/qutebrowser/widgets/statusbar/prompt.py b/qutebrowser/widgets/statusbar/prompt.py index 3da89e94e..5ee9ff1a0 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() + self.question.cancelled.emit() @cmdutils.register(instance='mainwindow.status.prompt', hide=True, modes=['prompt']) @@ -206,6 +199,6 @@ 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.loop.exec_() return self.question.answer From ad7856569fbd510a120bcf253ad08ecc2cf324af Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 17:50:09 +0200 Subject: [PATCH 20/48] Handle download errors and handle everything async --- qutebrowser/browser/downloads.py | 141 ++++++++++++++++++++++++------- 1 file changed, 111 insertions(+), 30 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 4f73b705e..1db71f935 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -20,6 +20,7 @@ import os.path 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 @@ -51,39 +52,53 @@ class DownloadItem(QObject): percentage_changed: The download percentage changed. arg: The new percentage, -1 if unknown. finished: The download was finished. + error: An error with the download occured. + arg: The error message as string. """ REFRESH_INTERVAL = 200 speed_changed = pyqtSignal(float) percentage_changed = pyqtSignal(int) finished = pyqtSignal() + error = pyqtSignal(str) - def __init__(self, reply, filename, parent=None): + def __init__(self, reply, parent=None): """Constructor. Args: reply: The QNetworkReply to download. - filename: The full filename to save the download to. """ super().__init__(parent) self.reply = reply self.bytes_done = None self.bytes_total = None self.speed = None + self.fileobj = None + self._do_delayed_write = False self._last_done = None self._last_percentage = None - # FIXME exceptions - self.fileobj = open(filename, 'wb') + reply.setReadBufferSize(16 * 1024 * 1024) reply.downloadProgress.connect(self.on_download_progress) - reply.finished.connect(self.on_finished) - reply.finished.connect(self.finished) - reply.error.connect(self.on_error) + 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.REFRESH_INTERVAL) self.timer.start() + 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.""" @@ -96,6 +111,48 @@ class DownloadItem(QObject): else: return 100 * self.bytes_done / self.bytes_total + def cancel(self): + """Cancel the download.""" + logger.debug("cancelled") + self.reply.abort() + self.reply.deleteLater() + 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.fileobj is not None: + raise ValueError("Filename was already set! filename: {}, " + "existing: {}".format(filename, self.fileobj)) + 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. @@ -113,21 +170,43 @@ class DownloadItem(QObject): self._last_percentage = perc @pyqtSlot() - def on_finished(self): - """Clean up when the download was finished.""" + 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() - self.fileobj.write(self.reply.readAll()) - self.fileobj.close() - self.reply.close() - self.reply.deleteLater() - logger.debug("Download finished") + 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.""" - # FIXME exceptions - self.fileobj.write(self.reply.readAll()) + 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): @@ -145,10 +224,6 @@ class DownloadItem(QObject): self._last_done = self.bytes_done self.speed_changed.emit(self.speed) - @pyqtSlot(int) - def on_error(self, code): - logger.debug("Error {} in download".format(code)) - class DownloadManager(QObject): @@ -213,16 +288,18 @@ class DownloadManager(QObject): suggested_filename = os.path.join(download_location, suggested_filename) logger.debug("fetch: {} -> {}".format(reply.url(), suggested_filename)) - filename = message.modular_question("Save file to:", PromptMode.text, - suggested_filename) - if filename is not None: - download = DownloadItem(reply, filename) - download.finished.connect(self.on_finished) - download.percentage_changed.connect(self.on_data_changed) - download.speed_changed.connect(self.on_data_changed) - self.download_about_to_be_added.emit(len(self.downloads) + 1) - self.downloads.append(download) - self.download_added.emit() + download = DownloadItem(reply) + download.finished.connect(self.on_finished) + download.percentage_changed.connect(self.on_data_changed) + download.speed_changed.connect(self.on_data_changed) + download.error.connect(self.on_error) + self.download_about_to_be_added.emit(len(self.downloads) + 1) + self.downloads.append(download) + self.download_added.emit() + message.question("Save file to:", mode=PromptMode.text, + handler=download.set_filename, + cancelled_handler=download.cancel, + default=suggested_filename) @pyqtSlot() def on_finished(self): @@ -235,3 +312,7 @@ class DownloadManager(QObject): def on_data_changed(self): idx = self.downloads.index(self.sender()) self.data_changed.emit(idx) + + @pyqtSlot(str) + def on_error(self, msg): + message.error("Download error: {}".format(msg), queue=True) From 3c2c08f73a1d3b94b6c43f7737e57ded0c5c6ebe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 17:56:28 +0200 Subject: [PATCH 21/48] Add missing docstrings --- qutebrowser/browser/downloads.py | 3 +++ qutebrowser/models/downloadmodel.py | 10 ++++++++++ qutebrowser/widgets/downloads.py | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 1db71f935..6140ebc3d 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -303,6 +303,7 @@ class DownloadManager(QObject): @pyqtSlot() def on_finished(self): + """Remove finished download.""" idx = self.downloads.index(self.sender()) self.download_about_to_be_finished.emit(idx) del self.downloads[idx] @@ -310,9 +311,11 @@ class DownloadManager(QObject): @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/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index 29fcef5e8..18fae8748 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -24,6 +24,12 @@ from PyQt5.QtWidgets import QApplication 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 @@ -37,10 +43,12 @@ class DownloadModel(QAbstractListModel): @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 headerData(self, section, orientation, role): + """Simple constant header.""" if (section == 0 and orientation == Qt.Horizontal and role == Qt.DisplayRole): return "Downloads" @@ -48,6 +56,7 @@ class DownloadModel(QAbstractListModel): return "" def data(self, index, role): + """Download data from DownloadManager.""" if not index.isValid(): return QVariant() elif role != Qt.DisplayRole: @@ -65,6 +74,7 @@ class DownloadModel(QAbstractListModel): return str(round(perc)) # FIXME def rowCount(self, parent): + """Get count of active downloads.""" if parent.isValid(): # We don't have children return 0 diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index 23ca51879..61cb4765f 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -25,6 +25,8 @@ from qutebrowser.models.downloadmodel import DownloadModel class DownloadView(QListView): + """QListView which shows currently running downloads as a bar.""" + def __init__(self, parent=None): super().__init__(parent) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) @@ -36,9 +38,11 @@ class DownloadView(QListView): self.setWrapping(True) 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.""" height = self.sizeHintForRow(0) if height != -1: return QSize(0, height + 2) From 2ffc9bb00a50f56f4f476d9eaa9770b1a67d111c Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 21:43:30 +0200 Subject: [PATCH 22/48] Add colors to DownloadView --- qutebrowser/browser/downloads.py | 11 ++++ qutebrowser/config/configdata.py | 16 ++++++ qutebrowser/config/conftypes.py | 36 ++++++++++++ qutebrowser/models/downloadmodel.py | 21 +++++-- qutebrowser/test/utils/test_misc.py | 89 +++++++++++++++++++++++++++++ qutebrowser/utils/misc.py | 69 ++++++++++++++++++++++ 6 files changed, 236 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 6140ebc3d..8cbec2035 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -26,6 +26,7 @@ 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 +from qutebrowser.utils.misc import interpolate_color class DownloadItem(QObject): @@ -111,6 +112,16 @@ class DownloadItem(QObject): else: return 100 * self.bytes_done / self.bytes_total + def bg_color(self): + """Background color to be shown.""" + start = config.get('colors', 'download.bg.start') + stop = config.get('colors', 'download.bg.stop') + system = config.get('colors', 'download.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") diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index bbc2834ff..6562cb130 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -894,6 +894,22 @@ DATA = OrderedDict([ 'left bottom, color-stop(0%,#FFF785), ' 'color-stop(100%,#FFC542))'), "Background color for hints."), + + ('download.fg', + SettingValue(types.QtColor(), '#ffffff'), + "Foreground color for downloads."), + + ('download.bg.start', + SettingValue(types.QtColor(), '#0000aa'), + "Color gradient start for downloads."), + + ('download.bg.stop', + SettingValue(types.QtColor(), '#00aa00'), + "Color gradient end for downloads."), + + ('download.bg.system', + SettingValue(types.ColorSystem(), 'rgb'), + "Color gradient interpolation system for downloads."), )), ('fonts', sect.KeyValue( diff --git a/qutebrowser/config/conftypes.py b/qutebrowser/config/conftypes.py index a8c0b215f..f6dfd6a09 100644 --- a/qutebrowser/config/conftypes.py +++ b/qutebrowser/config/conftypes.py @@ -434,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.""" diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index 18fae8748..1c28f6148 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -21,6 +21,8 @@ from PyQt5.QtCore import (pyqtSlot, Qt, QVariant, QAbstractListModel, QModelIndex) from PyQt5.QtWidgets import QApplication +import qutebrowser.config.config as config + class DownloadModel(QAbstractListModel): @@ -59,19 +61,26 @@ class DownloadModel(QAbstractListModel): """Download data from DownloadManager.""" if not index.isValid(): return QVariant() - elif role != Qt.DisplayRole: - return QVariant() elif index.parent().isValid() or index.column() != 0: return QVariant() + try: item = self.downloadmanager.downloads[index.row()] except IndexError: return QVariant() - perc = item.percentage - if perc is None: - return QVariant() + if role == Qt.DisplayRole: + perc = item.percentage + if perc is None: + data = QVariant() + else: + data = str(round(perc)) # FIXME + elif role == Qt.ForegroundRole: + data = config.get('colors', 'download.fg') + elif role == Qt.BackgroundRole: + data = item.bg_color() else: - return str(round(perc)) # FIXME + data = QVariant() + return data def rowCount(self, parent): """Get count of active downloads.""" diff --git a/qutebrowser/test/utils/test_misc.py b/qutebrowser/test/utils/test_misc.py index d9b042b3f..8cdace43e 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 @@ -436,5 +437,93 @@ class GetQtArgsTests(TestCase): self.assertEqual(utils.get_qt_args(ns), [sys.argv[0]]) +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) + + if __name__ == '__main__': unittest.main() diff --git a/qutebrowser/utils/misc.py b/qutebrowser/utils/misc.py index e2527aa0a..8ef39e166 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 @@ -285,3 +286,71 @@ def get_qt_args(namespace): argv.append('-' + argname) 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()) From 8c673ee66c69a62679fc6311547b803cec6a755a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 12 Jun 2014 23:29:34 +0200 Subject: [PATCH 23/48] Add basic download info to view --- qutebrowser/browser/downloads.py | 54 +++++++++++++++++++---------- qutebrowser/models/downloadmodel.py | 6 +--- qutebrowser/test/utils/test_misc.py | 28 +++++++++++++++ qutebrowser/utils/misc.py | 16 +++++++++ 4 files changed, 80 insertions(+), 24 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 8cbec2035..fe2825bba 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -26,7 +26,7 @@ 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 -from qutebrowser.utils.misc import interpolate_color +from qutebrowser.utils.misc import interpolate_color, format_seconds class DownloadItem(QObject): @@ -45,21 +45,17 @@ class DownloadItem(QObject): fileobj: The file object to download the file to. _last_done: The count of bytes which where downloaded when calculating the speed the last time. - _last_percentage: The remembered percentage for percentage_changed. + _last_percentage: The remembered percentage for data_changed. Signals: - speed_changed: The download speed changed. - arg: The speed in bytes/s - percentage_changed: The download percentage changed. - arg: The new percentage, -1 if unknown. + data_changed: The downloads metadata changed. finished: The download was finished. error: An error with the download occured. arg: The error message as string. """ REFRESH_INTERVAL = 200 - speed_changed = pyqtSignal(float) - percentage_changed = pyqtSignal(int) + data_changed = pyqtSignal() finished = pyqtSignal() error = pyqtSignal(str) @@ -74,6 +70,7 @@ class DownloadItem(QObject): self.bytes_done = None self.bytes_total = None self.speed = None + self.basename = '???' self.fileobj = None self._do_delayed_write = False self._last_done = None @@ -88,6 +85,19 @@ class DownloadItem(QObject): self.timer.setInterval(self.REFRESH_INTERVAL) self.timer.start() + def __str__(self): + """Get the download as a string. + + Example: foo.pdf [699.2K/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 + '?') + return '{name} [{speed}|{remaining}|{perc: 2}%|{down}/{total}]'.format( + name=self.basename, speed=self.speed, remaining=remaining, + perc=perc, down=self.bytes_done, total=self.bytes_total) + def _die(self, msg): """Abort the download and emit an error.""" self.error.emit(msg) @@ -112,6 +122,15 @@ class DownloadItem(QObject): 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 self.bytes_total <= 0 or + self.speed is None or self.speed == 0): + return None + return (self.bytes_total - self.bytes_done) / self.speed + + def bg_color(self): """Background color to be shown.""" start = config.get('colors', 'download.bg.start') @@ -139,6 +158,7 @@ class DownloadItem(QObject): if self.fileobj is not None: raise ValueError("Filename was already set! filename: {}, " "existing: {}".format(filename, self.fileobj)) + self.basename = os.path.basename(filename) try: self.fileobj = open(filename, 'wb') if self._do_delayed_write: @@ -174,11 +194,7 @@ class DownloadItem(QObject): """ self.bytes_done = bytes_done self.bytes_total = bytes_total - perc = round(self.percentage) - if perc != self._last_percentage: - logger.debug("{}% downloaded".format(perc)) - self.percentage_changed.emit(perc) - self._last_percentage = perc + self.data_changed.emit() @pyqtSlot() def on_reply_finished(self): @@ -233,7 +249,7 @@ class DownloadItem(QObject): self.speed = delta * 1000 / self.REFRESH_INTERVAL logger.debug("Download speed: {} bytes/sec".format(self.speed)) self._last_done = self.bytes_done - self.speed_changed.emit(self.speed) + self.data_changed.emit() class DownloadManager(QObject): @@ -296,21 +312,21 @@ class DownloadManager(QObject): """ suggested_filename = self._get_filename(reply) download_location = config.get('storage', 'download-directory') - suggested_filename = os.path.join(download_location, + suggested_filepath = os.path.join(download_location, suggested_filename) - logger.debug("fetch: {} -> {}".format(reply.url(), suggested_filename)) + logger.debug("fetch: {} -> {}".format(reply.url(), suggested_filepath)) download = DownloadItem(reply) download.finished.connect(self.on_finished) - download.percentage_changed.connect(self.on_data_changed) - download.speed_changed.connect(self.on_data_changed) + 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() message.question("Save file to:", mode=PromptMode.text, handler=download.set_filename, cancelled_handler=download.cancel, - default=suggested_filename) + default=suggested_filepath) @pyqtSlot() def on_finished(self): diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index 1c28f6148..773880ee2 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -69,11 +69,7 @@ class DownloadModel(QAbstractListModel): except IndexError: return QVariant() if role == Qt.DisplayRole: - perc = item.percentage - if perc is None: - data = QVariant() - else: - data = str(round(perc)) # FIXME + data = str(item) elif role == Qt.ForegroundRole: data = config.get('colors', 'download.fg') elif role == Qt.BackgroundRole: diff --git a/qutebrowser/test/utils/test_misc.py b/qutebrowser/test/utils/test_misc.py index 8cdace43e..8d7853587 100644 --- a/qutebrowser/test/utils/test_misc.py +++ b/qutebrowser/test/utils/test_misc.py @@ -525,5 +525,33 @@ class InterpolateColorTests(TestCase): 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) + + if __name__ == '__main__': unittest.main() diff --git a/qutebrowser/utils/misc.py b/qutebrowser/utils/misc.py index 8ef39e166..782a9d973 100644 --- a/qutebrowser/utils/misc.py +++ b/qutebrowser/utils/misc.py @@ -354,3 +354,19 @@ def interpolate_color(start, end, percent, colorspace=QColor.Rgb): 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) From efd83f40ca3cb9a088f85d2f0e0ff6203c89c0c1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 07:13:47 +0200 Subject: [PATCH 24/48] Print human readable sizes in downloads --- qutebrowser/browser/downloads.py | 10 +++++--- qutebrowser/test/utils/test_misc.py | 38 +++++++++++++++++++++++++++++ qutebrowser/utils/misc.py | 15 ++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index fe2825bba..727aca16c 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -26,7 +26,8 @@ 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 -from qutebrowser.utils.misc import interpolate_color, format_seconds +from qutebrowser.utils.misc import (interpolate_color, format_seconds, + format_size) class DownloadItem(QObject): @@ -94,9 +95,12 @@ class DownloadItem(QObject): 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}|{remaining}|{perc: 2}%|{down}/{total}]'.format( - name=self.basename, speed=self.speed, remaining=remaining, - perc=perc, down=self.bytes_done, total=self.bytes_total) + name=self.basename, speed=speed, remaining=remaining, perc=perc, + down=down, total=total) def _die(self, msg): """Abort the download and emit an error.""" diff --git a/qutebrowser/test/utils/test_misc.py b/qutebrowser/test/utils/test_misc.py index 8d7853587..ad197c8c5 100644 --- a/qutebrowser/test/utils/test_misc.py +++ b/qutebrowser/test/utils/test_misc.py @@ -553,5 +553,43 @@ class FormatSecondsTests(TestCase): 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/misc.py b/qutebrowser/utils/misc.py index 782a9d973..dbadd5bb6 100644 --- a/qutebrowser/utils/misc.py +++ b/qutebrowser/utils/misc.py @@ -370,3 +370,18 @@ def format_seconds(total_seconds): 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) From 9b7ff910c307dd1a054686762eeba7c1f527a95f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 07:38:10 +0200 Subject: [PATCH 25/48] Log stylesheets --- qutebrowser/config/style.py | 5 ++++- qutebrowser/utils/log.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) 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/utils/log.py b/qutebrowser/utils/log.py index b6ae5d007..ca27b0d2d 100644 --- a/qutebrowser/utils/log.py +++ b/qutebrowser/utils/log.py @@ -69,6 +69,7 @@ keyboard = getLogger('keyboard') downloads = getLogger('downloads') js = getLogger('js') qt = getLogger('qt') +style = getLogger('style') ram_handler = None From ac43a0b5cbbe6371e83b156b91d014c735014cdb Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 07:39:47 +0200 Subject: [PATCH 26/48] Style DownloadView --- qutebrowser/config/configdata.py | 8 ++++++++ qutebrowser/widgets/downloads.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 6562cb130..b2dec656c 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -899,6 +899,10 @@ DATA = OrderedDict([ SettingValue(types.QtColor(), '#ffffff'), "Foreground color for downloads."), + ('download.bg.bar', + SettingValue(types.Color(), '#555555'), + "Background color for the download bar."), + ('download.bg.start', SettingValue(types.QtColor(), '#0000aa'), "Color gradient start for downloads."), @@ -932,6 +936,10 @@ DATA = OrderedDict([ SettingValue(types.Font(), '8pt ${_monospace}'), "Font used in the statusbar."), + ('download', + 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/widgets/downloads.py b/qutebrowser/widgets/downloads.py index 61cb4765f..d16c230f2 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -21,14 +21,23 @@ from PyQt5.QtCore import QSize from PyQt5.QtWidgets import QListView, QSizePolicy from qutebrowser.models.downloadmodel import DownloadModel +from qutebrowser.config.style import set_register_stylesheet class DownloadView(QListView): """QListView which shows currently running downloads as a bar.""" + STYLESHEET = """ + QListView {{ + {color[download.bg.bar]} + {font[download]} + }} + """ + def __init__(self, parent=None): super().__init__(parent) + set_register_stylesheet(self) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.setFlow(QListView.LeftToRight) self._model = DownloadModel() From 4dc33102b727d3d738049f33466714bae7505e5f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 07:41:51 +0200 Subject: [PATCH 27/48] Cleanup --- qutebrowser/browser/downloads.py | 7 +++---- qutebrowser/config/configdata.py | 12 ++++++------ qutebrowser/models/downloadmodel.py | 2 +- qutebrowser/widgets/downloads.py | 4 ++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 727aca16c..fc82b1023 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -134,12 +134,11 @@ class DownloadItem(QObject): return None return (self.bytes_total - self.bytes_done) / self.speed - def bg_color(self): """Background color to be shown.""" - start = config.get('colors', 'download.bg.start') - stop = config.get('colors', 'download.bg.stop') - system = config.get('colors', 'download.bg.system') + 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: diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index b2dec656c..1b76c80c5 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -895,23 +895,23 @@ DATA = OrderedDict([ 'color-stop(100%,#FFC542))'), "Background color for hints."), - ('download.fg', + ('downloads.fg', SettingValue(types.QtColor(), '#ffffff'), "Foreground color for downloads."), - ('download.bg.bar', + ('downloads.bg.bar', SettingValue(types.Color(), '#555555'), "Background color for the download bar."), - ('download.bg.start', + ('downloads.bg.start', SettingValue(types.QtColor(), '#0000aa'), "Color gradient start for downloads."), - ('download.bg.stop', + ('downloads.bg.stop', SettingValue(types.QtColor(), '#00aa00'), "Color gradient end for downloads."), - ('download.bg.system', + ('downloads.bg.system', SettingValue(types.ColorSystem(), 'rgb'), "Color gradient interpolation system for downloads."), )), @@ -936,7 +936,7 @@ DATA = OrderedDict([ SettingValue(types.Font(), '8pt ${_monospace}'), "Font used in the statusbar."), - ('download', + ('downloads', SettingValue(types.Font(), '8pt ${_monospace}'), "Font used for the downloadbar."), diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index 773880ee2..86e039eca 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -71,7 +71,7 @@ class DownloadModel(QAbstractListModel): if role == Qt.DisplayRole: data = str(item) elif role == Qt.ForegroundRole: - data = config.get('colors', 'download.fg') + data = config.get('colors', 'downloads.fg') elif role == Qt.BackgroundRole: data = item.bg_color() else: diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index d16c230f2..bef91021a 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -30,8 +30,8 @@ class DownloadView(QListView): STYLESHEET = """ QListView {{ - {color[download.bg.bar]} - {font[download]} + {color[downloads.bg.bar]} + {font[downloads]} }} """ From c9f60caa12c30c611d6856643c37051a40be1cf5 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 07:42:53 +0200 Subject: [PATCH 28/48] Change download bar bg to black --- qutebrowser/config/configdata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 1b76c80c5..7554a4178 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -900,7 +900,7 @@ DATA = OrderedDict([ "Foreground color for downloads."), ('downloads.bg.bar', - SettingValue(types.Color(), '#555555'), + SettingValue(types.Color(), 'black'), "Background color for the download bar."), ('downloads.bg.start', From cd7d6b87f9de929b3f4ed33d987f97b59806692e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 07:49:21 +0200 Subject: [PATCH 29/48] Format donwload string so it jumps less --- qutebrowser/browser/downloads.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index fc82b1023..5adca9b8a 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -89,18 +89,18 @@ class DownloadItem(QObject): def __str__(self): """Get the download as a string. - Example: foo.pdf [699.2K/s|0.34|16%|4.253/25.124] + 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 - '?') + 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}|{remaining}|{perc: 2}%|{down}/{total}]'.format( - name=self.basename, speed=speed, remaining=remaining, perc=perc, - down=down, total=total) + 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.""" From b5ac700a9ef5a61a6d2535b6585a68eb7184b3b9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 07:50:46 +0200 Subject: [PATCH 30/48] Update BUGS --- BUGS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGS b/BUGS index 9b243e1d9..c1c6cbdb2 100644 --- a/BUGS +++ b/BUGS @@ -1,6 +1,8 @@ Bugs ==== +- When quitting while being asked for a filename: segfault / memory corruption + - When following a hint: QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once. From 3e5e8e59c1e0b023c21551251fb5a2705c3ffd35 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 12:19:30 +0200 Subject: [PATCH 31/48] Add right-click menu to cancel download --- qutebrowser/browser/downloads.py | 19 +++++++++++++++++-- qutebrowser/models/downloadmodel.py | 6 ++++++ qutebrowser/widgets/downloads.py | 28 ++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 5adca9b8a..c67cd6341 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -17,6 +17,7 @@ """Download manager.""" +import os import os.path from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer @@ -44,6 +45,8 @@ class DownloadItem(QObject): bytes_total: The total count of bytes. speed: The current download speed, in bytes per second. fileobj: The file object to download the file to. + filename: The filename of the download. + cancelled: Whether the download was cancelled. _last_done: The count of bytes which where downloaded when calculating the speed the last time. _last_percentage: The remembered percentage for data_changed. @@ -73,6 +76,8 @@ class DownloadItem(QObject): self.speed = None self.basename = '???' self.fileobj = None + self.filename = None + self.cancelled = False self._do_delayed_write = False self._last_done = None self._last_percentage = None @@ -147,10 +152,16 @@ class DownloadItem(QObject): def cancel(self): """Cancel the download.""" logger.debug("cancelled") + self.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. @@ -158,9 +169,10 @@ class DownloadItem(QObject): filename: The full filename to save the download to. None: special value to stop the download. """ - if self.fileobj is not None: + if self.filename is not None: raise ValueError("Filename was already set! filename: {}, " - "existing: {}".format(filename, self.fileobj)) + "existing: {}".format(filename, self.filename)) + self.filename = filename self.basename = os.path.basename(filename) try: self.fileobj = open(filename, 'wb') @@ -209,6 +221,8 @@ class DownloadItem(QObject): """ self.bytes_done = self.bytes_total self.timer.stop() + if self.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 @@ -334,6 +348,7 @@ class DownloadManager(QObject): @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] diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index 86e039eca..cc2c15f77 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -22,6 +22,10 @@ from PyQt5.QtCore import (pyqtSlot, Qt, QVariant, QAbstractListModel, 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): @@ -74,6 +78,8 @@ class DownloadModel(QAbstractListModel): 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 diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index bef91021a..322699bc7 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -17,16 +17,21 @@ """The ListView to display downloads in.""" -from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QListView, QSizePolicy +from PyQt5.QtCore import pyqtSlot, QSize, Qt +from PyQt5.QtWidgets import QListView, QSizePolicy, QMenu -from qutebrowser.models.downloadmodel import DownloadModel +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.""" + """QListView which shows currently running downloads as a bar. + + Attributes: + _menu: The QMenu which is currently displayed. + _model: The currently set model. + """ STYLESHEET = """ QListView {{ @@ -40,11 +45,26 @@ class DownloadView(QListView): set_register_stylesheet(self) 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.""" From a5f71a286dcee4f509fd2d627cf1cb082c8f6a4b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 17:47:03 +0200 Subject: [PATCH 32/48] Don't make download items selectable --- qutebrowser/models/downloadmodel.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index cc2c15f77..df58fb34d 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -84,6 +84,12 @@ class DownloadModel(QAbstractListModel): 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): """Get count of active downloads.""" if parent.isValid(): From 5a2e6ba99ac7cc7d8fe289d194a448862c434c8f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 18:16:24 +0200 Subject: [PATCH 33/48] Add answer as argument to question's answered signal. --- qutebrowser/utils/message.py | 2 +- qutebrowser/utils/usertypes.py | 3 ++- qutebrowser/widgets/statusbar/prompt.py | 12 ++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index a9a1f519b..cdd6dabc6 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -103,7 +103,7 @@ def question(message, mode, handler, cancelled_handler=None, default=None): q.text = message q.mode = mode q.default = default - q.answered.connect(lambda: handler(q.answer)) + q.answered.connect(handler) if cancelled_handler is not None: q.cancelled.connect(cancelled_handler) instance().question.emit(q, True) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index f90947641..5be8a4b6b 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -255,6 +255,7 @@ class Question(QObject): 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. answered_yes: Convienience signal emitted when a yesno question was answered with yes. @@ -262,7 +263,7 @@ class Question(QObject): answered with no. """ - answered = pyqtSignal() + answered = pyqtSignal(str) cancelled = pyqtSignal() answered_yes = pyqtSignal() answered_no = pyqtSignal() diff --git a/qutebrowser/widgets/statusbar/prompt.py b/qutebrowser/widgets/statusbar/prompt.py index 5ee9ff1a0..db734cb81 100644 --- a/qutebrowser/widgets/statusbar/prompt.py +++ b/qutebrowser/widgets/statusbar/prompt.py @@ -93,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!") @@ -121,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, @@ -133,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): From 7b1e502dbf86775ce085bdcc58151b2ab1b2b2e1 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 18:16:47 +0200 Subject: [PATCH 34/48] Actually make message.question async. --- qutebrowser/utils/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/utils/message.py b/qutebrowser/utils/message.py index cdd6dabc6..e8c567c49 100644 --- a/qutebrowser/utils/message.py +++ b/qutebrowser/utils/message.py @@ -106,7 +106,7 @@ def question(message, mode, handler, cancelled_handler=None, default=None): q.answered.connect(handler) if cancelled_handler is not None: q.cancelled.connect(cancelled_handler) - instance().question.emit(q, True) + instance().question.emit(q, False) def confirm_action(message, yes_action, no_action=None, default=None): From fce591839b91510d261d40fe6b77d57e868b7c3d Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 18:17:27 +0200 Subject: [PATCH 35/48] Abort filename prompt when download is cancelled. --- qutebrowser/browser/downloads.py | 26 ++++++++++++++++--------- qutebrowser/utils/usertypes.py | 14 +++++++++++++ qutebrowser/widgets/statusbar/prompt.py | 4 +++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index c67cd6341..10e81c2f3 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -26,7 +26,7 @@ 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 +from qutebrowser.utils.usertypes import PromptMode, Question from qutebrowser.utils.misc import (interpolate_color, format_seconds, format_size) @@ -46,7 +46,7 @@ class DownloadItem(QObject): speed: The current download speed, in bytes per second. fileobj: The file object to download the file to. filename: The filename of the download. - cancelled: Whether the download was cancelled. + is_cancelled: Whether the download was cancelled. _last_done: The count of bytes which where downloaded when calculating the speed the last time. _last_percentage: The remembered percentage for data_changed. @@ -54,6 +54,7 @@ class DownloadItem(QObject): 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. """ @@ -62,6 +63,7 @@ class DownloadItem(QObject): data_changed = pyqtSignal() finished = pyqtSignal() error = pyqtSignal(str) + cancelled = pyqtSignal() def __init__(self, reply, parent=None): """Constructor. @@ -77,7 +79,7 @@ class DownloadItem(QObject): self.basename = '???' self.fileobj = None self.filename = None - self.cancelled = False + self.is_cancelled = False self._do_delayed_write = False self._last_done = None self._last_percentage = None @@ -152,7 +154,8 @@ class DownloadItem(QObject): def cancel(self): """Cancel the download.""" logger.debug("cancelled") - self.cancelled = True + self.cancelled.emit() + self.is_cancelled = True self.reply.abort() self.reply.deleteLater() if self.fileobj is not None: @@ -221,7 +224,7 @@ class DownloadItem(QObject): """ self.bytes_done = self.bytes_total self.timer.stop() - if self.cancelled: + if self.is_cancelled: return logger.debug("Reply finished, fileobj {}".format(self.fileobj)) if self.fileobj is None: @@ -340,10 +343,15 @@ class DownloadManager(QObject): self.download_about_to_be_added.emit(len(self.downloads) + 1) self.downloads.append(download) self.download_added.emit() - message.question("Save file to:", mode=PromptMode.text, - handler=download.set_filename, - cancelled_handler=download.cancel, - default=suggested_filepath) + + 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) + download.cancelled.connect(q.abort) + message.instance().question.emit(q, False) @pyqtSlot() def on_finished(self): diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 5be8a4b6b..dba32e03e 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -250,6 +250,7 @@ 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. @@ -257,6 +258,8 @@ class Question(QObject): 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 @@ -265,6 +268,7 @@ class Question(QObject): answered = pyqtSignal(str) cancelled = pyqtSignal() + aborted = pyqtSignal() answered_yes = pyqtSignal() answered_no = pyqtSignal() @@ -275,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/statusbar/prompt.py b/qutebrowser/widgets/statusbar/prompt.py index db734cb81..acc1cf52d 100644 --- a/qutebrowser/widgets/statusbar/prompt.py +++ b/qutebrowser/widgets/statusbar/prompt.py @@ -69,7 +69,7 @@ class Prompt(QWidget): self._input.clear() self._input.setEchoMode(QLineEdit.Normal) self.hide_prompt.emit() - if self.question.answer is None: + if self.question.answer is None and not self.question.is_aborted: self.question.cancelled.emit() @cmdutils.register(instance='mainwindow.status.prompt', hide=True, @@ -173,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) @@ -200,5 +201,6 @@ class Prompt(QWidget): """ self.question.answered.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 From 47c1908da2a774ac13f66d0069088fcc425aea1f Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 20:18:16 +0200 Subject: [PATCH 36/48] Keep a reference to download path Question objects. --- qutebrowser/browser/downloads.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 10e81c2f3..e26aace2d 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -276,6 +276,10 @@ 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. @@ -296,6 +300,7 @@ class DownloadManager(QObject): 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. @@ -350,6 +355,7 @@ class DownloadManager(QObject): 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) From ba1f8e37647f1a1b4cf69a676ffe5eb22f4fda9e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 20:18:42 +0200 Subject: [PATCH 37/48] Don't require parent argument for rowCount in DownloadModel. --- qutebrowser/models/downloadmodel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index df58fb34d..b9ba5027e 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -90,7 +90,7 @@ class DownloadModel(QAbstractListModel): The default would be Qt.ItemIsEnabled | Qt.ItemIsSelectable.""" return Qt.ItemIsEnabled - def rowCount(self, parent): + def rowCount(self, parent=QModelIndex()): """Get count of active downloads.""" if parent.isValid(): # We don't have children From 6579f4dba02c113fa2b4d65b459834a1c6cd60a3 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 20:19:00 +0200 Subject: [PATCH 38/48] Resize DownloadModel based on last item --- qutebrowser/models/downloadmodel.py | 4 ++++ qutebrowser/widgets/downloads.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index b9ba5027e..a5cca560d 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -53,6 +53,10 @@ class DownloadModel(QAbstractListModel): 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 diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index 322699bc7..ef41852d7 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -72,7 +72,8 @@ class DownloadView(QListView): def sizeHint(self): """Return sizeHint based on the view contents.""" - height = self.sizeHintForRow(0) + idx = self.model().last_index() + height = self.visualRect(idx).bottom() if height != -1: return QSize(0, height + 2) else: From aa36d3b10cf88734076c325542dd8a92d7566ac0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 20:19:27 +0200 Subject: [PATCH 39/48] Relayout DownloadView when size changed. --- qutebrowser/widgets/downloads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index ef41852d7..9854fd1a8 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -43,6 +43,7 @@ class DownloadView(QListView): def __init__(self, parent=None): super().__init__(parent) set_register_stylesheet(self) + self.setResizeMode(QListView.Adjust) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.setFlow(QListView.LeftToRight) self._menu = None From 82ed50050d6c2db84404befe7ef5420871e2a2b0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 20:19:36 +0200 Subject: [PATCH 40/48] Update DownloadView geometry when MainWindow size changed. --- qutebrowser/widgets/mainwindow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/widgets/mainwindow.py b/qutebrowser/widgets/mainwindow.py index 6a21b34ae..8a9023b1c 100644 --- a/qutebrowser/widgets/mainwindow.py +++ b/qutebrowser/widgets/mainwindow.py @@ -136,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.""" From d80c05b0b11b4be5b845501e1ec92774185aabf9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 21:43:04 +0200 Subject: [PATCH 41/48] Decrease download speed refresh interval --- qutebrowser/browser/downloads.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index e26aace2d..88d5acf9c 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -36,7 +36,7 @@ class DownloadItem(QObject): """A single download currently running. Class attributes: - REFRESH_INTERVAL: How often to refresh the speed, in msec. + SPEED_REFRESH_INTERVAL: How often to refresh the speed, in msec. Attributes: reply: The QNetworkReply associated with this download. @@ -59,7 +59,7 @@ class DownloadItem(QObject): arg: The error message as string. """ - REFRESH_INTERVAL = 200 + SPEED_REFRESH_INTERVAL = 500 data_changed = pyqtSignal() finished = pyqtSignal() error = pyqtSignal(str) @@ -90,7 +90,7 @@ class DownloadItem(QObject): reply.readyRead.connect(self.on_ready_read) self.timer = QTimer() self.timer.timeout.connect(self.update_speed) - self.timer.setInterval(self.REFRESH_INTERVAL) + self.timer.setInterval(self.SPEED_REFRESH_INTERVAL) self.timer.start() def __str__(self): @@ -263,10 +263,10 @@ class DownloadItem(QObject): self.speed = 0 else: delta = self.bytes_done - self.speed = delta * 1000 / self.REFRESH_INTERVAL + self.speed = delta * 1000 / self.SPEED_REFRESH_INTERVAL else: delta = self.bytes_done - self._last_done - self.speed = delta * 1000 / self.REFRESH_INTERVAL + self.speed = delta * 1000 / self.SPEED_REFRESH_INTERVAL logger.debug("Download speed: {} bytes/sec".format(self.speed)) self._last_done = self.bytes_done self.data_changed.emit() From 85ee71b739940f40a5bf31fc4ee33cedd7e8dc72 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 21:53:06 +0200 Subject: [PATCH 42/48] Add a rolling average of dl speed for time estimation --- qutebrowser/browser/downloads.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 88d5acf9c..2936a96b8 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -19,6 +19,7 @@ import os import os.path +from collections import deque from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer from PyQt5.QtNetwork import QNetworkReply @@ -37,6 +38,8 @@ class DownloadItem(QObject): 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. @@ -47,6 +50,7 @@ class DownloadItem(QObject): 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. _last_percentage: The remembered percentage for data_changed. @@ -60,6 +64,7 @@ class DownloadItem(QObject): """ SPEED_REFRESH_INTERVAL = 500 + SPEED_AVG_WINDOW = 30 data_changed = pyqtSignal() finished = pyqtSignal() error = pyqtSignal(str) @@ -77,6 +82,9 @@ class DownloadItem(QObject): self.bytes_total = None self.speed = None 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 @@ -137,9 +145,11 @@ class DownloadItem(QObject): def remaining_time(self): """Property to get the remaining download time in seconds.""" if (self.bytes_total is None or self.bytes_total <= 0 or - self.speed is None or self.speed == 0): + self.speed is None or not self.speed_avg): return None - return (self.bytes_total - self.bytes_done) / self.speed + remaining_bytes = self.bytes_total - self.bytes_done + speed = sum(self.speed_avg) / len(self.speed_avg) + return remaining_bytes / speed def bg_color(self): """Background color to be shown.""" @@ -267,7 +277,7 @@ class DownloadItem(QObject): else: delta = self.bytes_done - self._last_done self.speed = delta * 1000 / self.SPEED_REFRESH_INTERVAL - logger.debug("Download speed: {} bytes/sec".format(self.speed)) + self.speed_avg.append(self.speed) self._last_done = self.bytes_done self.data_changed.emit() From 5d6cb0e45d73ab5ed718309b474528635b5b2a4e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 22:51:16 +0200 Subject: [PATCH 43/48] Clean up download value calculations --- qutebrowser/browser/downloads.py | 43 +++++++++++++++----------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 2936a96b8..613fae74d 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -44,16 +44,19 @@ class DownloadItem(QObject): 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. - _last_percentage: The remembered percentage for data_changed. Signals: data_changed: The downloads metadata changed. @@ -78,9 +81,8 @@ class DownloadItem(QObject): """ super().__init__(parent) self.reply = reply - self.bytes_done = None self.bytes_total = None - self.speed = None + self.speed = 0 self.basename = '???' samples = int(self.SPEED_AVG_WINDOW * (1000 / self.SPEED_REFRESH_INTERVAL)) @@ -89,8 +91,8 @@ class DownloadItem(QObject): self.filename = None self.is_cancelled = False self._do_delayed_write = False - self._last_done = None - self._last_percentage = None + 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) @@ -132,11 +134,7 @@ class DownloadItem(QObject): @property def percentage(self): """Property to get the current download percentage.""" - if self.bytes_total == -1: - return -1 - elif self.bytes_total == 0: - return 0 - elif self.bytes_done is None or self.bytes_total is None: + if self.bytes_total == 0 or self.bytes_total is None: return None else: return 100 * self.bytes_done / self.bytes_total @@ -144,12 +142,16 @@ class DownloadItem(QObject): @property def remaining_time(self): """Property to get the remaining download time in seconds.""" - if (self.bytes_total is None or self.bytes_total <= 0 or - self.speed is None or not self.speed_avg): + 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 - speed = sum(self.speed_avg) / len(self.speed_avg) - return remaining_bytes / speed + 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.""" @@ -220,6 +222,8 @@ class DownloadItem(QObject): 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() @@ -268,15 +272,8 @@ class DownloadItem(QObject): @pyqtSlot() def update_speed(self): """Recalculate the current download speed.""" - if self._last_done is None: - if self.bytes_done is None: - self.speed = 0 - else: - delta = self.bytes_done - self.speed = delta * 1000 / self.SPEED_REFRESH_INTERVAL - else: - delta = self.bytes_done - self._last_done - self.speed = delta * 1000 / self.SPEED_REFRESH_INTERVAL + 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() From 0e11bae002f652326e5c9a7789c87b18845b0559 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 22:53:56 +0200 Subject: [PATCH 44/48] Turn off scrollbars in DownloadView --- qutebrowser/widgets/downloads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index 9854fd1a8..ae0e24666 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -44,6 +44,7 @@ class DownloadView(QListView): 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 From 9cd0369f89fb36f4f4b54f81bf833efda0a656b7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 22:55:24 +0200 Subject: [PATCH 45/48] Add some space between download items --- qutebrowser/widgets/downloads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index ae0e24666..e5711be48 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -45,6 +45,7 @@ class DownloadView(QListView): set_register_stylesheet(self) self.setResizeMode(QListView.Adjust) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setSpacing(2) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.setFlow(QListView.LeftToRight) self._menu = None From 197bbf1a95030edb60a178a37b413027bcfcf3bc Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 23:06:20 +0200 Subject: [PATCH 46/48] Fix spacing between download items --- qutebrowser/widgets/downloads.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qutebrowser/widgets/downloads.py b/qutebrowser/widgets/downloads.py index e5711be48..cd401b053 100644 --- a/qutebrowser/widgets/downloads.py +++ b/qutebrowser/widgets/downloads.py @@ -38,6 +38,10 @@ class DownloadView(QListView): {color[downloads.bg.bar]} {font[downloads]} }} + + QListView::item {{ + padding-right: 2px; + }} """ def __init__(self, parent=None): @@ -45,7 +49,6 @@ class DownloadView(QListView): set_register_stylesheet(self) self.setResizeMode(QListView.Adjust) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setSpacing(2) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) self.setFlow(QListView.LeftToRight) self._menu = None From ba108e89aca97ba0630f3096efc1a9935758fbab Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 23:08:23 +0200 Subject: [PATCH 47/48] Update BUGS --- BUGS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BUGS b/BUGS index c1c6cbdb2..98b853c3a 100644 --- a/BUGS +++ b/BUGS @@ -1,7 +1,8 @@ Bugs ==== -- When quitting while being asked for a filename: segfault / memory corruption +- When quitting while being asked for a download filename: segfault / memory + corruption - When following a hint: From 9b42617c7648654c4b3b51399e9298c5bed990a2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 13 Jun 2014 23:09:24 +0200 Subject: [PATCH 48/48] Fix lint --- qutebrowser/browser/downloads.py | 1 - qutebrowser/models/downloadmodel.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 613fae74d..f91749560 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -176,7 +176,6 @@ class DownloadItem(QObject): os.remove(self.filename) self.finished.emit() - def set_filename(self, filename): """Set the filename to save the download to. diff --git a/qutebrowser/models/downloadmodel.py b/qutebrowser/models/downloadmodel.py index a5cca560d..1945e98b2 100644 --- a/qutebrowser/models/downloadmodel.py +++ b/qutebrowser/models/downloadmodel.py @@ -88,7 +88,7 @@ class DownloadModel(QAbstractListModel): data = QVariant() return data - def flags(self, index): + def flags(self, _index): """Override flags so items aren't selectable. The default would be Qt.ItemIsEnabled | Qt.ItemIsSelectable."""