Merge branch 'downloads'

Conflicts:
	BUGS
This commit is contained in:
Florian Bruhin 2014-06-13 23:11:44 +02:00
commit 6d4f961a50
17 changed files with 1006 additions and 24 deletions

3
BUGS
View File

@ -10,6 +10,9 @@ Bugs
e.g. when trying to open a link with %20 interactively, or even via click.
- When quitting while being asked for a download filename: segfault / memory
corruption
- When following a hint:
QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once.

View File

@ -53,6 +53,8 @@ from qutebrowser.commands.managers import CommandManager, SearchManager
from qutebrowser.config.iniparsers import ReadWriteConfigParser
from qutebrowser.config.lineparser import LineConfigParser
from qutebrowser.browser.cookies import CookieJar
from qutebrowser.browser.downloads import DownloadManager
from qutebrowser.models.downloadmodel import DownloadModel
from qutebrowser.utils.message import MessageBridge
from qutebrowser.utils.misc import (get_standard_dir, actute_warning,
get_qt_args)
@ -132,6 +134,8 @@ class Application(QApplication):
self.networkmanager = NetworkManager(self.cookiejar)
self.commandmanager = CommandManager()
self.searchmanager = SearchManager()
self.downloadmanager = DownloadManager()
self.downloadmodel = DownloadModel(self.downloadmanager)
self.mainwindow = MainWindow()
self.modeman.mainwindow = self.mainwindow
@ -386,6 +390,9 @@ class Application(QApplication):
cmd.update_completion.connect(completer.on_update_completion)
completer.change_completed_part.connect(cmd.on_change_completed_part)
# downloads
tabs.start_download.connect(self.downloadmanager.fetch)
def _recover_pages(self):
"""Try to recover all open pages.

View File

@ -0,0 +1,386 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""Download manager."""
import os
import os.path
from collections import deque
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QTimer
from PyQt5.QtNetwork import QNetworkReply
import qutebrowser.config.config as config
import qutebrowser.utils.message as message
from qutebrowser.utils.log import downloads as logger
from qutebrowser.utils.usertypes import PromptMode, Question
from qutebrowser.utils.misc import (interpolate_color, format_seconds,
format_size)
class DownloadItem(QObject):
"""A single download currently running.
Class attributes:
SPEED_REFRESH_INTERVAL: How often to refresh the speed, in msec.
SPEED_AVG_WINDOW: How many seconds of speed data to average to
estimate the remaining time.
Attributes:
reply: The QNetworkReply associated with this download.
percentage: How many percent were downloaded successfully.
None if unknown.
bytes_done: How many bytes there are already downloaded.
bytes_total: The total count of bytes.
None if the total is unknown.
speed: The current download speed, in bytes per second.
remaining_time: The time remaining for the download.
None if not enough data is available yet.
fileobj: The file object to download the file to.
filename: The filename of the download.
is_cancelled: Whether the download was cancelled.
speed_avg: A rolling average of speeds.
_last_done: The count of bytes which where downloaded when calculating
the speed the last time.
Signals:
data_changed: The downloads metadata changed.
finished: The download was finished.
cancelled: The download was cancelled.
error: An error with the download occured.
arg: The error message as string.
"""
SPEED_REFRESH_INTERVAL = 500
SPEED_AVG_WINDOW = 30
data_changed = pyqtSignal()
finished = pyqtSignal()
error = pyqtSignal(str)
cancelled = pyqtSignal()
def __init__(self, reply, parent=None):
"""Constructor.
Args:
reply: The QNetworkReply to download.
"""
super().__init__(parent)
self.reply = reply
self.bytes_total = None
self.speed = 0
self.basename = '???'
samples = int(self.SPEED_AVG_WINDOW *
(1000 / self.SPEED_REFRESH_INTERVAL))
self.speed_avg = deque(maxlen=samples)
self.fileobj = None
self.filename = None
self.is_cancelled = False
self._do_delayed_write = False
self.bytes_done = 0
self._last_done = 0
reply.setReadBufferSize(16 * 1024 * 1024)
reply.downloadProgress.connect(self.on_download_progress)
reply.finished.connect(self.on_reply_finished)
reply.error.connect(self.on_reply_error)
reply.readyRead.connect(self.on_ready_read)
self.timer = QTimer()
self.timer.timeout.connect(self.update_speed)
self.timer.setInterval(self.SPEED_REFRESH_INTERVAL)
self.timer.start()
def __str__(self):
"""Get the download as a string.
Example: foo.pdf [699.2kB/s|0.34|16%|4.253/25.124]
"""
perc = 0 if self.percentage is None else round(self.percentage)
remaining = (format_seconds(self.remaining_time)
if self.remaining_time is not None else '?')
speed = format_size(self.speed, suffix='B/s')
down = format_size(self.bytes_done, suffix='B')
total = format_size(self.bytes_total, suffix='B')
return ('{name} [{speed:>10}|{remaining:>5}|{perc:>2}%|'
'{down}/{total}]'.format(name=self.basename, speed=speed,
remaining=remaining, perc=perc,
down=down, total=total))
def _die(self, msg):
"""Abort the download and emit an error."""
self.error.emit(msg)
self.reply.abort()
self.reply.deleteLater()
if self.fileobj is not None:
try:
self.fileobj.close()
except OSError as e:
self.error.emit(e.strerror)
self.finished.emit()
@property
def percentage(self):
"""Property to get the current download percentage."""
if self.bytes_total == 0 or self.bytes_total is None:
return None
else:
return 100 * self.bytes_done / self.bytes_total
@property
def remaining_time(self):
"""Property to get the remaining download time in seconds."""
if self.bytes_total is None or not self.speed_avg:
# No average yet or we don't know the total size.
return None
remaining_bytes = self.bytes_total - self.bytes_done
avg = sum(self.speed_avg) / len(self.speed_avg)
if avg == 0:
# Download stalled
return None
else:
return remaining_bytes / avg
def bg_color(self):
"""Background color to be shown."""
start = config.get('colors', 'downloads.bg.start')
stop = config.get('colors', 'downloads.bg.stop')
system = config.get('colors', 'downloads.bg.system')
if self.percentage is None:
return start
else:
return interpolate_color(start, stop, self.percentage, system)
def cancel(self):
"""Cancel the download."""
logger.debug("cancelled")
self.cancelled.emit()
self.is_cancelled = True
self.reply.abort()
self.reply.deleteLater()
if self.fileobj is not None:
self.fileobj.close()
if self.filename is not None:
os.remove(self.filename)
self.finished.emit()
def set_filename(self, filename):
"""Set the filename to save the download to.
Args:
filename: The full filename to save the download to.
None: special value to stop the download.
"""
if self.filename is not None:
raise ValueError("Filename was already set! filename: {}, "
"existing: {}".format(filename, self.filename))
self.filename = filename
self.basename = os.path.basename(filename)
try:
self.fileobj = open(filename, 'wb')
if self._do_delayed_write:
# Downloading to the buffer in RAM has already finished so we
# write out the data and clean up now.
self.delayed_write()
else:
# Since the buffer already might be full, on_ready_read might
# not be called at all anymore, so we force it here to flush
# the buffer and continue receiving new data.
self.on_ready_read()
except OSError as e:
self._die(e.strerror)
def delayed_write(self):
"""Write buffered data to disk and finish the QNetworkReply."""
logger.debug("Doing delayed write...")
self._do_delayed_write = False
self.fileobj.write(self.reply.readAll())
self.fileobj.close()
self.reply.close()
self.reply.deleteLater()
self.finished.emit()
logger.debug("Download finished")
@pyqtSlot(int, int)
def on_download_progress(self, bytes_done, bytes_total):
"""Upload local variables when the download progress changed.
Args:
bytes_done: How many bytes are downloaded.
bytes_total: How many bytes there are to download in total.
"""
if bytes_total == -1:
bytes_total = None
self.bytes_done = bytes_done
self.bytes_total = bytes_total
self.data_changed.emit()
@pyqtSlot()
def on_reply_finished(self):
"""Clean up when the download was finished.
Note when this gets called, only the QNetworkReply has finished. This
doesn't mean the download (i.e. writing data to the disk) is finished
as well. Therefore, we can't close() the QNetworkReply in here yet.
"""
self.bytes_done = self.bytes_total
self.timer.stop()
if self.is_cancelled:
return
logger.debug("Reply finished, fileobj {}".format(self.fileobj))
if self.fileobj is None:
# We'll handle emptying the buffer and cleaning up as soon as the
# filename is set.
self._do_delayed_write = True
else:
# We can do a "delayed" write immediately to empty the buffer and
# clean up.
self.delayed_write()
@pyqtSlot()
def on_ready_read(self):
"""Read available data and save file when ready to read."""
if self.fileobj is None:
# No filename has been set yet, so we don't empty the buffer.
return
try:
self.fileobj.write(self.reply.readAll())
except OSError as e:
self._die(e.strerror)
@pyqtSlot(int)
def on_reply_error(self, code):
"""Handle QNetworkReply errors."""
if code == QNetworkReply.OperationCanceledError:
return
else:
self.error.emit(self.reply.errorString())
@pyqtSlot()
def update_speed(self):
"""Recalculate the current download speed."""
delta = self.bytes_done - self._last_done
self.speed = delta * 1000 / self.SPEED_REFRESH_INTERVAL
self.speed_avg.append(self.speed)
self._last_done = self.bytes_done
self.data_changed.emit()
class DownloadManager(QObject):
"""Manager for running downloads.
Attributes:
downloads: A list of active DownloadItems.
questions: A list of Question objects to not GC them.
Signals:
download_about_to_be_added: A new download will be added.
arg: The index of the new download.
download_added: A new download was added.
download_about_to_be_finished: A download will be finished and removed.
arg: The index of the new download.
download_finished: A download was finished and removed.
data_changed: The data to be displayed in a model changed.
arg: The index of the download which changed.
"""
download_about_to_be_added = pyqtSignal(int)
download_added = pyqtSignal()
download_about_to_be_finished = pyqtSignal(int)
download_finished = pyqtSignal()
data_changed = pyqtSignal(int)
def __init__(self, parent=None):
super().__init__(parent)
self.downloads = []
self.questions = []
def _get_filename(self, reply):
"""Get a suitable filename to download a file to.
Args:
reply: The QNetworkReply to get a filename for.
"""
filename = None
# First check if the Content-Disposition header has a filename
# attribute.
if reply.hasRawHeader('Content-Disposition'):
header = reply.rawHeader('Content-Disposition')
data = header.split(':', maxsplit=1)[1].strip()
for pair in data.split(';'):
if '=' in pair:
key, value = pair.split('=')
if key == 'filename':
filename = value.strip('"')
break
# Then try to get filename from url
if not filename:
filename = reply.url().path()
# If that fails as well, use a fallback
if not filename:
filename = 'qutebrowser-download'
return os.path.basename(filename)
@pyqtSlot('QNetworkReply')
def fetch(self, reply):
"""Download a QNetworkReply to disk.
Args:
reply: The QNetworkReply to download.
"""
suggested_filename = self._get_filename(reply)
download_location = config.get('storage', 'download-directory')
suggested_filepath = os.path.join(download_location,
suggested_filename)
logger.debug("fetch: {} -> {}".format(reply.url(), suggested_filepath))
download = DownloadItem(reply)
download.finished.connect(self.on_finished)
download.data_changed.connect(self.on_data_changed)
download.error.connect(self.on_error)
download.basename = suggested_filename
self.download_about_to_be_added.emit(len(self.downloads) + 1)
self.downloads.append(download)
self.download_added.emit()
q = Question()
q.text = "Save file to:"
q.mode = PromptMode.text
q.default = suggested_filepath
q.answered.connect(download.set_filename)
q.cancelled.connect(download.cancel)
self.questions.append(q)
download.cancelled.connect(q.abort)
message.instance().question.emit(q, False)
@pyqtSlot()
def on_finished(self):
"""Remove finished download."""
logger.debug("on_finished: {}".format(self.sender()))
idx = self.downloads.index(self.sender())
self.download_about_to_be_finished.emit(idx)
del self.downloads[idx]
self.download_finished.emit()
@pyqtSlot()
def on_data_changed(self):
"""Emit data_changed signal when download data changed."""
idx = self.downloads.index(self.sender())
self.data_changed.emit(idx)
@pyqtSlot(str)
def on_error(self, msg):
"""Display error message on download errors."""
message.error("Download error: {}".format(msg), queue=True)

View File

@ -18,7 +18,7 @@
"""The main browser widgets."""
import sip
from PyQt5.QtCore import QCoreApplication
from PyQt5.QtCore import QCoreApplication, pyqtSignal, pyqtSlot
from PyQt5.QtNetwork import QNetworkReply
from PyQt5.QtWidgets import QFileDialog
from PyQt5.QtPrintSupport import QPrintDialog
@ -39,8 +39,13 @@ class BrowserPage(QWebPage):
Attributes:
_extension_handlers: Mapping of QWebPage extensions to their handlers.
network_access_manager: The QNetworkAccessManager used.
Signals:
start_download: Emitted when a file should be downloaded.
"""
start_download = pyqtSignal('QNetworkReply*')
def __init__(self, parent=None):
super().__init__(parent)
self._extension_handlers = {
@ -49,7 +54,9 @@ class BrowserPage(QWebPage):
}
self.setNetworkAccessManager(
QCoreApplication.instance().networkmanager)
self.setForwardUnsupportedContent(True)
self.printRequested.connect(self.on_print_requested)
self.downloadRequested.connect(self.on_download_requested)
def _handle_errorpage(self, opt, out):
"""Display an error page if needed.
@ -118,6 +125,17 @@ class BrowserPage(QWebPage):
printdiag = QPrintDialog()
printdiag.open(lambda: frame.print(printdiag.printer()))
@pyqtSlot('QNetworkRequest')
def on_download_requested(self, request):
"""Called when the user wants to download a link.
Emit:
start_download: Emitted with the QNetworkReply associated with the
passed request.
"""
reply = self.networkAccessManager().get(request)
self.start_download.emit(reply)
def userAgentForUrl(self, url):
"""Override QWebPage::userAgentForUrl to customize the user agent."""
ua = config.get('network', 'user-agent')

View File

@ -438,6 +438,11 @@ DATA = OrderedDict([
)),
('storage', sect.KeyValue(
('download-directory',
SettingValue(types.Directory(none=True), ''),
"The directory to save downloads to. An empty value selects a "
"sensible os-specific default."),
('maximum-pages-in-cache',
SettingValue(types.Int(none=True, minval=0, maxval=MAXVALS['int']),
''),
@ -889,6 +894,26 @@ DATA = OrderedDict([
'left bottom, color-stop(0%,#FFF785), '
'color-stop(100%,#FFC542))'),
"Background color for hints."),
('downloads.fg',
SettingValue(types.QtColor(), '#ffffff'),
"Foreground color for downloads."),
('downloads.bg.bar',
SettingValue(types.Color(), 'black'),
"Background color for the download bar."),
('downloads.bg.start',
SettingValue(types.QtColor(), '#0000aa'),
"Color gradient start for downloads."),
('downloads.bg.stop',
SettingValue(types.QtColor(), '#00aa00'),
"Color gradient end for downloads."),
('downloads.bg.system',
SettingValue(types.ColorSystem(), 'rgb'),
"Color gradient interpolation system for downloads."),
)),
('fonts', sect.KeyValue(
@ -911,6 +936,10 @@ DATA = OrderedDict([
SettingValue(types.Font(), '8pt ${_monospace}'),
"Font used in the statusbar."),
('downloads',
SettingValue(types.Font(), '8pt ${_monospace}'),
"Font used for the downloadbar."),
('hints',
SettingValue(types.Font(), 'bold 12px Monospace'),
"Font used for the hints."),

View File

@ -22,11 +22,12 @@ import shlex
import os.path
from sre_constants import error as RegexError
from PyQt5.QtCore import QUrl
from PyQt5.QtCore import QUrl, QStandardPaths
from PyQt5.QtGui import QColor
from PyQt5.QtNetwork import QNetworkProxy
import qutebrowser.commands.utils as cmdutils
from qutebrowser.utils.misc import get_standard_dir
class ValidationError(ValueError):
@ -433,6 +434,42 @@ class Command(BaseType):
return out
class ColorSystem(BaseType):
"""Color systems for interpolation."""
valid_values = ValidValues(('rgb', "Interpolate in the RGB color system."),
('hsv', "Interpolate in the HSV color system."),
('hsl', "Interpolate in the HSV color system."))
def validate(self, value):
super().validate(value.lower())
def transform(self, value):
mapping = {
'rgb': QColor.Rgb,
'hsv': QColor.Hsv,
'hsl': QColor.Hsl,
}
return mapping[value.lower()]
class QtColor(BaseType):
"""Base class for QColor."""
typestr = 'qcolor'
def validate(self, value):
if QColor.isValidColor(value):
pass
else:
raise ValidationError(value, "must be a valid color")
def transform(self, value):
return QColor(value)
class CssColor(BaseType):
"""Base class for a CSS color value."""
@ -530,6 +567,31 @@ class File(BaseType):
raise ValidationError(value, "must be a valid file!")
class Directory(BaseType):
"""A directory on the local filesystem.
Attributes:
none: Whether to accept empty values as None.
"""
typestr = 'directory'
def __init__(self, none=False):
self.none = none
def validate(self, value):
if self.none and not value:
return
if not os.path.isdir(value):
raise ValidationError(value, "must be a valid directory!")
def transform(self, value):
if not value:
return get_standard_dir(QStandardPaths.DownloadLocation)
return value
class WebKitBytes(BaseType):
"""A size with an optional suffix.

View File

@ -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))

View File

@ -0,0 +1,102 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""Glue code for qutebrowser.{browser,widgets}.download."""
from PyQt5.QtCore import (pyqtSlot, Qt, QVariant, QAbstractListModel,
QModelIndex)
from PyQt5.QtWidgets import QApplication
import qutebrowser.config.config as config
from qutebrowser.utils.usertypes import enum
Role = enum('item', start=Qt.UserRole)
class DownloadModel(QAbstractListModel):
"""Glue model to show downloads in a QListView.
Glue between qutebrowser.browser.download (DownloadManager) and
qutebrowser.widgets.download (DownloadView).
"""
def __init__(self, parent=None):
super().__init__(parent)
self.downloadmanager = QApplication.instance().downloadmanager
self.downloadmanager.download_about_to_be_added.connect(
lambda idx: self.beginInsertRows(QModelIndex(), idx, idx))
self.downloadmanager.download_added.connect(self.endInsertRows)
self.downloadmanager.download_about_to_be_finished.connect(
lambda idx: self.beginRemoveRows(QModelIndex(), idx, idx))
self.downloadmanager.download_finished.connect(self.endRemoveRows)
self.downloadmanager.data_changed.connect(self.on_data_changed)
@pyqtSlot(int)
def on_data_changed(self, idx):
"""Update view when DownloadManager data changed."""
model_idx = self.index(idx, 0)
self.dataChanged.emit(model_idx, model_idx)
def last_index(self):
"""Get the last index in the model."""
return self.index(self.rowCount() - 1)
def headerData(self, section, orientation, role):
"""Simple constant header."""
if (section == 0 and orientation == Qt.Horizontal and
role == Qt.DisplayRole):
return "Downloads"
else:
return ""
def data(self, index, role):
"""Download data from DownloadManager."""
if not index.isValid():
return QVariant()
elif index.parent().isValid() or index.column() != 0:
return QVariant()
try:
item = self.downloadmanager.downloads[index.row()]
except IndexError:
return QVariant()
if role == Qt.DisplayRole:
data = str(item)
elif role == Qt.ForegroundRole:
data = config.get('colors', 'downloads.fg')
elif role == Qt.BackgroundRole:
data = item.bg_color()
elif role == Role.item:
data = item
else:
data = QVariant()
return data
def flags(self, _index):
"""Override flags so items aren't selectable.
The default would be Qt.ItemIsEnabled | Qt.ItemIsSelectable."""
return Qt.ItemIsEnabled
def rowCount(self, parent=QModelIndex()):
"""Get count of active downloads."""
if parent.isValid():
# We don't have children
return 0
return len(self.downloadmanager.downloads)

View File

@ -30,6 +30,7 @@ from tempfile import mkdtemp
from unittest import TestCase
from PyQt5.QtCore import QStandardPaths, QCoreApplication
from PyQt5.QtGui import QColor
import qutebrowser.utils.misc as utils
from qutebrowser.test.helpers import environ_set_temp
@ -433,5 +434,159 @@ class GetQtArgsTests(TestCase):
self.assertIn('foobar', qt_args)
class InterpolateColorTests(TestCase):
"""Tests for interpolate_color.
Attributes:
white: The QColor white as a valid QColor for tests.
white: The QColor black as a valid QColor for tests.
"""
def setUp(self):
self.white = QColor('white')
self.black = QColor('black')
def test_invalid_start(self):
"""Test an invalid start color."""
with self.assertRaises(ValueError):
utils.interpolate_color(QColor(), self.white, 0)
def test_invalid_end(self):
"""Test an invalid end color."""
with self.assertRaises(ValueError):
utils.interpolate_color(self.white, QColor(), 0)
def test_invalid_percentage(self):
"""Test an invalid percentage."""
with self.assertRaises(ValueError):
utils.interpolate_color(self.white, self.white, -1)
with self.assertRaises(ValueError):
utils.interpolate_color(self.white, self.white, 101)
def test_invalid_colorspace(self):
"""Test an invalid colorspace."""
with self.assertRaises(ValueError):
utils.interpolate_color(self.white, self.black, 10, QColor.Cmyk)
def test_valid_percentages_rgb(self):
"""Test 0% and 100% in the RGB colorspace."""
white = utils.interpolate_color(self.white, self.black, 0, QColor.Rgb)
black = utils.interpolate_color(self.white, self.black, 100,
QColor.Rgb)
self.assertEqual(white, self.white)
self.assertEqual(black, self.black)
def test_valid_percentages_hsv(self):
"""Test 0% and 100% in the HSV colorspace."""
white = utils.interpolate_color(self.white, self.black, 0, QColor.Hsv)
black = utils.interpolate_color(self.white, self.black, 100,
QColor.Hsv)
self.assertEqual(white, self.white)
self.assertEqual(black, self.black)
def test_valid_percentages_hsl(self):
"""Test 0% and 100% in the HSL colorspace."""
white = utils.interpolate_color(self.white, self.black, 0, QColor.Hsl)
black = utils.interpolate_color(self.white, self.black, 100,
QColor.Hsl)
self.assertEqual(white, self.white)
self.assertEqual(black, self.black)
def test_interpolation_rgb(self):
"""Test an interpolation in the RGB colorspace."""
color = utils.interpolate_color(QColor(0, 40, 100), QColor(0, 20, 200),
50, QColor.Rgb)
self.assertEqual(color, QColor(0, 30, 150))
def test_interpolation_hsv(self):
"""Test an interpolation in the HSV colorspace."""
start = QColor()
stop = QColor()
start.setHsv(0, 40, 100)
stop.setHsv(0, 20, 200)
color = utils.interpolate_color(start, stop, 50, QColor.Hsv)
expected = QColor()
expected.setHsv(0, 30, 150)
self.assertEqual(color, expected)
def test_interpolation_hsl(self):
"""Test an interpolation in the HSL colorspace."""
start = QColor()
stop = QColor()
start.setHsl(0, 40, 100)
stop.setHsl(0, 20, 200)
color = utils.interpolate_color(start, stop, 50, QColor.Hsl)
expected = QColor()
expected.setHsl(0, 30, 150)
self.assertEqual(color, expected)
class FormatSecondsTests(TestCase):
"""Tests for format_seconds.
Class attributes:
TESTS: A list of (input, output) tuples.
"""
TESTS = [
(-1, '-0:01'),
(0, '0:00'),
(59, '0:59'),
(60, '1:00'),
(60.4, '1:00'),
(61, '1:01'),
(-61, '-1:01'),
(3599, '59:59'),
(3600, '1:00:00'),
(3601, '1:00:01'),
(36000, '10:00:00'),
]
def test_format_seconds(self):
"""Test format_seconds with several tests."""
for seconds, out in self.TESTS:
self.assertEqual(utils.format_seconds(seconds), out, seconds)
class FormatSizeTests(TestCase):
"""Tests for format_size.
Class attributes:
TESTS: A list of (input, output) tuples.
"""
TESTS = [
(-1024, '-1.00k'),
(-1, '-1.00'),
(0, '0.00'),
(1023, '1023.00'),
(1024, '1.00k'),
(1034.24, '1.01k'),
(1024 * 1024 * 2, '2.00M'),
(1024 ** 10, '1024.00Y'),
(None, '?.??'),
]
def test_format_size(self):
"""Test format_size with several tests."""
for size, out in self.TESTS:
self.assertEqual(utils.format_size(size), out, size)
def test_suffix(self):
"""Test the suffix option."""
for size, out in self.TESTS:
self.assertEqual(utils.format_size(size, suffix='B'), out + 'B',
size)
def test_base(self):
"""Test with an alternative base."""
kilo_tests = [(999, '999.00'), (1000, '1.00k'), (1010, '1.01k')]
for size, out in kilo_tests:
self.assertEqual(utils.format_size(size, base=1000), out, size)
if __name__ == '__main__':
unittest.main()

View File

@ -66,8 +66,10 @@ init = getLogger('init')
signals = getLogger('signals')
hints = getLogger('hints')
keyboard = getLogger('keyboard')
downloads = getLogger('downloads')
js = getLogger('js')
qt = getLogger('qt')
style = getLogger('style')
ram_handler = None

View File

@ -88,21 +88,25 @@ def alert(message):
instance().question.emit(q, True)
def question(message, mode, handler, default=None):
def question(message, mode, handler, cancelled_handler=None, default=None):
"""Ask an async question in the statusbar.
Args:
message: The message to display to the user.
mode: A PromptMode.
handler: The function to get called with the answer as argument.
cancelled_handler: The function to get called when the prompt was
cancelled by the user, or None.
default: The default value to display.
"""
q = Question()
q.text = message
q.mode = mode
q.default = default
q.answered.connect(lambda: handler(q.answer))
instance().question.emit(q, True)
q.answered.connect(handler)
if cancelled_handler is not None:
q.cancelled.connect(cancelled_handler)
instance().question.emit(q, False)
def confirm_action(message, yes_action, no_action=None, default=None):

View File

@ -33,6 +33,7 @@ from functools import reduce
from distutils.version import StrictVersion as Version
from PyQt5.QtCore import QCoreApplication, QStandardPaths, qVersion
from PyQt5.QtGui import QColor
from pkg_resources import resource_string
import qutebrowser
@ -284,3 +285,102 @@ def get_qt_args(namespace):
argv.append('-' + argname[3:])
argv.append(val[0])
return argv
def _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3, percent):
"""Get a color which is percent% interpolated between start and end.
Args:
a_c1, a_c2, a_c3: Start color components (R, G, B / H, S, V / H, S, L)
b_c1, b_c2, b_c3: End color components (R, G, B / H, S, V / H, S, L)
percent: Percentage to interpolate, 0-100.
0: Start color will be returned.
100: End color will be returned.
Return:
A (c1, c2, c3) tuple with the interpolated color components.
Raise:
ValueError if the percentage was invalid.
"""
if not 0 <= percent <= 100:
raise ValueError("percent needs to be between 0 and 100!")
out_c1 = round(a_c1 + (b_c1 - a_c1) * percent / 100)
out_c2 = round(a_c2 + (b_c2 - a_c2) * percent / 100)
out_c3 = round(a_c3 + (b_c3 - a_c3) * percent / 100)
return (out_c1, out_c2, out_c3)
def interpolate_color(start, end, percent, colorspace=QColor.Rgb):
"""Get an interpolated color value.
Args:
start: The start color.
end: The end color.
percent: Which value to get (0 - 100)
colorspace: The desired interpolation colorsystem,
QColor::{Rgb,Hsv,Hsl} (from QColor::Spec enum)
Return:
The interpolated QColor, with the same spec as the given start color.
Raise:
ValueError if invalid parameters are passed.
"""
if not start.isValid():
raise ValueError("Invalid start color")
if not end.isValid():
raise ValueError("Invalid end color")
out = QColor()
if colorspace == QColor.Rgb:
a_c1, a_c2, a_c3, _alpha = start.getRgb()
b_c1, b_c2, b_c3, _alpha = end.getRgb()
components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3,
percent)
out.setRgb(*components)
elif colorspace == QColor.Hsv:
a_c1, a_c2, a_c3, _alpha = start.getHsv()
b_c1, b_c2, b_c3, _alpha = end.getHsv()
components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3,
percent)
out.setHsv(*components)
elif colorspace == QColor.Hsl:
a_c1, a_c2, a_c3, _alpha = start.getHsl()
b_c1, b_c2, b_c3, _alpha = end.getHsl()
components = _get_color_percentage(a_c1, a_c2, a_c3, b_c1, b_c2, b_c3,
percent)
out.setHsl(*components)
else:
raise ValueError("Invalid colorspace!")
return out.convertTo(start.spec())
def format_seconds(total_seconds):
"""Format a count of seconds to get a [H:]M:SS string."""
prefix = '-' if total_seconds < 0 else ''
hours, rem = divmod(abs(round(total_seconds)), 3600)
minutes, seconds = divmod(rem, 60)
chunks = []
if hours:
chunks.append(str(hours))
min_format = '{:02}'
else:
min_format = '{}'
chunks.append(min_format.format(minutes))
chunks.append('{:02}'.format(seconds))
return prefix + ':'.join(chunks)
def format_size(size, base=1024, suffix=''):
"""Format a byte size so it's human readable.
Inspired by http://stackoverflow.com/q/1094841
"""
prefixes = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
if size is None:
return '?.??' + suffix
for p in prefixes:
if -base < size < base:
return '{:.02f}{}{}'.format(size, p, suffix)
size /= base
return '{:.02f}{}{}'.format(size, prefixes[-1], suffix)

View File

@ -250,18 +250,25 @@ class Question(QObject):
text: The prompt text to display to the user.
user: The value the user entered as username.
answer: The value the user entered (as password for user_pwd).
is_aborted: Whether the question was aborted.
Signals:
answered: Emitted when the question has been answered by the user.
This is emitted from qutebrowser.widgets.statusbar._prompt so
it can be emitted after the mode is left.
arg: The answer to the question.
cancelled: Emitted when the question has been cancelled by the user.
aborted: Emitted when the question was aborted programatically.
In this case, cancelled is not emitted.
answered_yes: Convienience signal emitted when a yesno question was
answered with yes.
answered_no: Convienience signal emitted when a yesno question was
answered with no.
"""
answered = pyqtSignal()
answered = pyqtSignal(str)
cancelled = pyqtSignal()
aborted = pyqtSignal()
answered_yes = pyqtSignal()
answered_no = pyqtSignal()
@ -272,3 +279,13 @@ class Question(QObject):
self.text = None
self.user = None
self.answer = None
self.is_aborted = False
def abort(self):
"""Abort the question.
Emit:
aborted: Always emitted.
"""
self.is_aborted = True
self.aborted.emit()

View File

@ -0,0 +1,86 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""The ListView to display downloads in."""
from PyQt5.QtCore import pyqtSlot, QSize, Qt
from PyQt5.QtWidgets import QListView, QSizePolicy, QMenu
from qutebrowser.models.downloadmodel import DownloadModel, Role
from qutebrowser.config.style import set_register_stylesheet
class DownloadView(QListView):
"""QListView which shows currently running downloads as a bar.
Attributes:
_menu: The QMenu which is currently displayed.
_model: The currently set model.
"""
STYLESHEET = """
QListView {{
{color[downloads.bg.bar]}
{font[downloads]}
}}
QListView::item {{
padding-right: 2px;
}}
"""
def __init__(self, parent=None):
super().__init__(parent)
set_register_stylesheet(self)
self.setResizeMode(QListView.Adjust)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
self.setFlow(QListView.LeftToRight)
self._menu = None
self._model = DownloadModel()
self._model.rowsInserted.connect(self.updateGeometry)
self._model.rowsRemoved.connect(self.updateGeometry)
self.setModel(self._model)
self.setWrapping(True)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_context_menu)
@pyqtSlot('QPoint')
def show_context_menu(self, point):
"""Show the context menu."""
index = self.indexAt(point)
if not index.isValid():
return
item = self.model().data(index, Role.item)
self._menu = QMenu()
cancel = self._menu.addAction("Cancel")
cancel.triggered.connect(item.cancel)
self._menu.popup(self.viewport().mapToGlobal(point))
def minimumSizeHint(self):
"""Override minimumSizeHint so the size is correct in a layout."""
return self.sizeHint()
def sizeHint(self):
"""Return sizeHint based on the view contents."""
idx = self.model().last_index()
height = self.visualRect(idx).bottom()
if height != -1:
return QSize(0, height + 2)
else:
return QSize(0, 0)

View File

@ -30,6 +30,7 @@ import qutebrowser.utils.message as message
from qutebrowser.widgets.statusbar.bar import StatusBar
from qutebrowser.widgets.tabbedbrowser import TabbedBrowser
from qutebrowser.widgets.completion import CompletionView
from qutebrowser.widgets.downloads import DownloadView
from qutebrowser.utils.usertypes import PromptMode
@ -43,6 +44,7 @@ class MainWindow(QWidget):
Attributes:
tabs: The TabbedBrowser widget.
status: The StatusBar widget.
downloadview: The DownloadView widget.
_vbox: The main QVBoxLayout.
"""
@ -68,6 +70,10 @@ class MainWindow(QWidget):
self._vbox.setContentsMargins(0, 0, 0, 0)
self._vbox.setSpacing(0)
self.downloadview = DownloadView()
self._vbox.addWidget(self.downloadview)
self.downloadview.show()
self.tabs = TabbedBrowser()
self._vbox.addWidget(self.tabs)
@ -130,6 +136,7 @@ class MainWindow(QWidget):
"""
super().resizeEvent(e)
self.resize_completion()
self.downloadview.updateGeometry()
def closeEvent(self, e):
"""Override closeEvent to display a confirmation if needed."""

View File

@ -41,12 +41,10 @@ class Prompt(QWidget):
Signals:
show_prompt: Emitted when the prompt widget wants to be shown.
hide_prompt: Emitted when the prompt widget wants to be hidden.
cancelled: Emitted when the prompt was cancelled by the user.
"""
show_prompt = pyqtSignal()
hide_prompt = pyqtSignal()
cancelled = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
@ -65,19 +63,14 @@ class Prompt(QWidget):
self._hbox.addWidget(self._input)
def on_mode_left(self, mode):
"""Clear and reset input when the mode was left.
Emit:
cancelled: Emitted when the mode was forcibly left by the user
without answering the question.
"""
"""Clear and reset input when the mode was left."""
if mode in ('prompt', 'yesno'):
self._txt.setText('')
self._input.clear()
self._input.setEchoMode(QLineEdit.Normal)
self.hide_prompt.emit()
if self.question.answer is None:
self.cancelled.emit()
if self.question.answer is None and not self.question.is_aborted:
self.question.cancelled.emit()
@cmdutils.register(instance='mainwindow.status.prompt', hide=True,
modes=['prompt'])
@ -100,22 +93,22 @@ class Prompt(QWidget):
self.question.answer = (self.question.user, password)
modeman.leave('prompt', 'prompt accept')
self.hide_prompt.emit()
self.question.answered.emit()
self.question.answered.emit(self.question.answer)
elif self.question.mode == PromptMode.text:
# User just entered text.
self.question.answer = self._input.text()
modeman.leave('prompt', 'prompt accept')
self.question.answered.emit()
self.question.answered.emit(self.question.answer)
elif self.question.mode == PromptMode.yesno:
# User wants to accept the default of a yes/no question.
self.question.answer = self.question.default
modeman.leave('yesno', 'yesno accept')
self.question.answered.emit()
self.question.answered.emit(self.question.answer)
elif self.question.mode == PromptMode.alert:
# User acknowledged an alert
self.question.answer = None
modeman.leave('prompt', 'alert accept')
self.question.answered.emit()
self.question.answered.emit(self.question.answer)
else:
raise ValueError("Invalid question mode!")
@ -128,7 +121,7 @@ class Prompt(QWidget):
return
self.question.answer = True
modeman.leave('yesno', 'yesno accept')
self.question.answered.emit()
self.question.answered.emit(self.question.answer)
self.question.answered_yes.emit()
@cmdutils.register(instance='mainwindow.status.prompt', hide=True,
@ -140,7 +133,7 @@ class Prompt(QWidget):
return
self.question.answer = False
modeman.leave('yesno', 'prompt accept')
self.question.answered.emit()
self.question.answered.emit(self.question.answer)
self.question.answered_no.emit()
def display(self):
@ -180,6 +173,7 @@ class Prompt(QWidget):
raise ValueError("Invalid prompt mode!")
self._input.setFocus()
self.show_prompt.emit()
q.aborted.connect(lambda: modeman.maybe_leave(mode, 'aborted'))
modeman.enter(mode, 'question asked')
@pyqtSlot(Question, bool)
@ -206,6 +200,7 @@ class Prompt(QWidget):
The answer to the question. No, it's not always 42.
"""
self.question.answered.connect(self.loop.quit)
self.cancelled.connect(self.loop.quit)
self.question.cancelled.connect(self.loop.quit)
self.question.aborted.connect(self.loop.quit)
self.loop.exec_()
return self.question.answer

View File

@ -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)