Merge branch 'downloads'
Conflicts: BUGS
This commit is contained in:
commit
6d4f961a50
3
BUGS
3
BUGS
@ -10,6 +10,9 @@ Bugs
|
||||
|
||||
e.g. when trying to open a link with %20 interactively, or even via click.
|
||||
|
||||
- When quitting while being asked for a download filename: segfault / memory
|
||||
corruption
|
||||
|
||||
- When following a hint:
|
||||
|
||||
QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once.
|
||||
|
@ -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.
|
||||
|
||||
|
386
qutebrowser/browser/downloads.py
Normal file
386
qutebrowser/browser/downloads.py
Normal 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)
|
@ -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')
|
||||
|
@ -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."),
|
||||
|
@ -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.
|
||||
|
@ -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))
|
||||
|
||||
|
||||
|
102
qutebrowser/models/downloadmodel.py
Normal file
102
qutebrowser/models/downloadmodel.py
Normal 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)
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
86
qutebrowser/widgets/downloads.py
Normal file
86
qutebrowser/widgets/downloads.py
Normal 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)
|
@ -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."""
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user