diff --git a/TODO b/TODO index a23a7eeb5..943e3dc23 100644 --- a/TODO +++ b/TODO @@ -14,34 +14,26 @@ Crashes - Chosing printer in :print gives us a segfault on Linux, but :printpreview works... -- Segfault when closing some tab: - QIODevice::read: device not open - QIODevice::read: device not open - QIODevice::read: device not open - Fatal Python error: Segmentation fault +- When following a hint: - Current thread 0x00007ff4ed080700 (most recent call first): - File "/home/florian/proj/qutebrowser/git/qutebrowser/__main__.py", line 29 in main - File "/home/florian/proj/qutebrowser/git/qutebrowser/__main__.py", line 33 in - File "/usr/lib/python3.4/runpy.py", line 86 in _run_code - File "/usr/lib/python3.4/runpy.py", line 171 in _run_module_as_main + QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once. - @ e5000c315dd29ae9356e1b33ed041917c637c85b +- When enabling javascript on http://google.com/accounts and reloading: -- Weird TypeError: + OpenType support missing for script 12 - QIODevice::read: device not open - QIODevice::read: device not open - Traceback (most recent call last): - File "/home/florian/proj/qutebrowser/git/qutebrowser/app.py", line 567, in command_handler - handler(*args) - File "/home/florian/proj/qutebrowser/git/qutebrowser/widgets/_tabbedbrowser.py", line 216, in tabclose - tab.shutdown(callback=partial(self._cb_tab_shutdown, tab)) - File "/home/florian/proj/qutebrowser/git/qutebrowser/widgets/webview.py", line 215, in shutdown - netman.destroyed.connect(functools.partial(self._on_destroyed, netman)) - TypeError: pyqtSignal must be bound to a QObject, not 'NetworkManager' + Then it quits. + When javascript is enabled already, hangs. + With minimal browser, prints error but continues to run. + + Also quits on http://de.wikipedia.org/wiki/Hallo-Welt-Programm + + It seems this only happens on Windows. + +- Closing some tabs and undo all them -> quits and CMD.exe hangs + + QHttpNetworkConnectionPrivate::_q_hostLookupFinished could not dequeu request - @ e5000c315dd29ae9356e1b33ed041917c637c85b - Sometimes self._frame is None in on_mode_left in HintManager @@ -52,6 +44,24 @@ Bugs ==== - All kind of FIXMEs +- F on duckduckgo result page opens in current page + + It seems we don't get a linkClicked signal there. + Maybe it does some weird js stuff? + See also: http://qt-project.org/doc/qt-4.8/qwebpage.html#createWindow + Asked here: http://stackoverflow.com/q/23498666/2085149 + +- Shutdown is still flaky. + + Some pointers: + https://code.google.com/p/webscraping/source/browse/webkit.py + Simply does setPage(None) in __del__ of webview. + + http://www.tenox.net/out/wrp11-qt.py + does del self._window; del self._view; del self._page + + http://pydoc.net/Python/grab/0.4.5/ghost.ghost/ + does webview.close(); del self.manager; del self.page; del self.mainframe Style ===== @@ -80,6 +90,9 @@ Major features Minor features ============== +- Enable disk caching + QNetworkManager.setCache() and use a QNetworkDiskCache probably +- clear cookies - keybind/aliases should have completion for commands/arguments - Hiding scrollbars - Ctrl+A/X to increase/decrease last number in URL @@ -102,8 +115,15 @@ hints - filter close hints when it's the same link - ignore keypresses shortly after link following -Qt Bugs -======== +Useful things +============= + +http://www.tenox.net/out/wrp11-qt.py +https://code.google.com/p/webscraping/source/browse/webkit.py +https://github.com/jeanphix/Ghost.py/blob/master/ghost/ghost.py + +Upstream Bugs +============= - Printing under windows produced blank pages https://bugreports.qt-project.org/browse/QTBUG-19571 @@ -116,11 +136,54 @@ Qt Bugs https://bugreports.qt-project.org/browse/QTBUG-38669 - Web inspector is blank unless .hide()/.show() is called. + Asked on SO: http://stackoverflow.com/q/23499159/2085149 - Weird font rendering https://bugreports.qt-project.org/browse/QTBUG-20973 https://bugreports.qt-project.org/browse/QTBUG-21036 +- dead_actute + https://bugs.freedesktop.org/show_bug.cgi?id=69476 + +- QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once. + https://bugreports.qt-project.org/browse/QTBUG-30298 + + +Probably fixed crashes +====================== + +- Segfault when closing some tab: + QIODevice::read: device not open + QIODevice::read: device not open + QIODevice::read: device not open + Fatal Python error: Segmentation fault + + Current thread 0x00007ff4ed080700 (most recent call first): + File "/home/florian/proj/qutebrowser/git/qutebrowser/__main__.py", line 29 in main + File "/home/florian/proj/qutebrowser/git/qutebrowser/__main__.py", line 33 in + File "/usr/lib/python3.4/runpy.py", line 86 in _run_code + File "/usr/lib/python3.4/runpy.py", line 171 in _run_module_as_main + + @ e5000c315dd29ae9356e1b33ed041917c637c85b + + Probably fixed by de7c6a63b48f66e164bf4baa6e892bcbb326d6b9 + +- When opening a hint with F: + + 2014-05-06 09:01:25 [DEBUG] [_tabbedbrowser:tabopen:153] Opening PyQt5.QtCore.QUrl('http://ddg.gg/') + Traceback (most recent call last): + File ".\qutebrowser\widgets\_tabbedbrowser.py", line 155, in tabopen + tab = WebView(self) + File ".\qutebrowser\widgets\webview.py", line 83, in __init__ + self.page_ = BrowserPage(self) + File ".\qutebrowser\browser\webpage.py", line 44, in __init__ + self.setNetworkAccessManager(QApplication.instance().networkmanager) + RuntimeError: wrapped C/C++ object of type NetworkManager has been deleted + + Probably fixed by de7c6a63b48f66e164bf4baa6e892bcbb326d6b9 + + + Keybinding stuff (from dwb) =========================== diff --git a/qutebrowser/app.py b/qutebrowser/app.py index ef35657ac..be935f887 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -20,9 +20,10 @@ import os import sys import logging -import functools import subprocess import configparser +from bdb import BdbQuit +from functools import partial from signal import signal, SIGINT from argparse import ArgumentParser from base64 import b64encode @@ -102,7 +103,11 @@ class QuteBrowser(QApplication): def __init__(self): super().__init__(sys.argv) - self._quit_status = {} + self._quit_status = { + 'crash': True, + 'tabs': False, + 'networkmanager': False + } self._timers = [] self._opened_urls = [] self._shutting_down = False @@ -392,15 +397,22 @@ class QuteBrowser(QApplication): exc = (exctype, excvalue, tb) sys.__excepthook__(*exc) - if not issubclass(exctype, Exception): - # probably a KeyboardInterrupt + self._quit_status['crash'] = False + + # Give key input back for crash dialog + try: + self.removeEventFilter(self.modeman) + except AttributeError: + # self.modeman could be None + pass + + if exctype is BdbQuit or not issubclass(exctype, Exception): + # pdb exit, KeyboardInterrupt, ... try: self.shutdown() return except Exception: self.quit() - self._quit_status['crash'] = False - self._quit_status['shutdown'] = False try: pages = self._recover_pages() except Exception: @@ -483,49 +495,55 @@ class QuteBrowser(QApplication): @pyqtSlot() @cmdutils.register(instance='', name=['quit', 'q'], nargs=0) - def shutdown(self, do_quit=True): + def shutdown(self): """Try to shutdown everything cleanly. For some reason lastWindowClosing sometimes seem to get emitted twice, so we make sure we only run once here. - - Args: - do_quit: Whether to quit after shutting down. """ if self._shutting_down: return self._shutting_down = True - logging.debug("Shutting down... (do_quit={})".format(do_quit)) + logging.debug("Shutting down...") + # Save config if self.config.get('general', 'auto-save-config'): try: self.config.save() except AttributeError: logging.exception("Could not save config.") + # Save command history try: self.cmd_history.save() except AttributeError: logging.exception("Could not save command history.") + # Save window state try: self._save_geometry() self.stateconfig.save() except AttributeError: logging.exception("Could not save window geometry.") + # Save cookies try: self.cookiejar.save() except AttributeError: logging.exception("Could not save cookies.") + # Shut down tabs try: - if do_quit: - self.mainwindow.tabs.shutdown_complete.connect( - self.on_tab_shutdown_complete) - else: - self.mainwindow.tabs.shutdown_complete.connect( - functools.partial(self._maybe_quit, 'shutdown')) + self.mainwindow.tabs.shutdown_complete.connect(partial( + self._maybe_quit, 'tabs')) self.mainwindow.tabs.shutdown() except AttributeError: # mainwindow or tabs could still be None logging.exception("No mainwindow/tabs to shut down.") - if do_quit: - self.quit() + self._maybe_quit('tabs') + # Shut down networkmanager + try: + self.networkmanager.abort_requests() + self.networkmanager.destroyed.connect(partial( + self._maybe_quit, 'networkmanager')) + self.networkmanager.deleteLater() + except AttributeError: + logging.exception("No networkmanager to shut down.") + self._maybe_quit('networkmanager') @pyqtSlot() def on_tab_shutdown_complete(self): diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index dde970263..f7ba636e3 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -21,7 +21,7 @@ import logging import math from collections import namedtuple -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QEvent, Qt +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QPoint from PyQt5.QtGui import QMouseEvent, QClipboard from PyQt5.QtWidgets import QApplication @@ -70,6 +70,7 @@ class HintManager(QObject): """ HINT_CSS = """ + display: {display}; color: {config[colors][hints.fg]}; background: {config[colors][hints.bg]}; font: {config[fonts][hints]}; @@ -193,6 +194,24 @@ class HintManager(QObject): hintstr.insert(0, chars[0]) return ''.join(hintstr) + def _get_hint_css(self, elem, label=None): + """Get the hint CSS for the element given. + + Args: + elem: The QWebElement to get the CSS for. + label: The label QWebElement if display: none should be preserved. + + Return: + The CSS to set as a string. + """ + if label is None or label.attribute('hidden') != 'true': + display = 'inline' + else: + display = 'none' + rect = elem.geometry() + return self.HINT_CSS.format(left=rect.x(), top=rect.y(), + config=config.instance(), display=display) + def _draw_label(self, elem, string): """Draw a hint label over an element. @@ -203,9 +222,7 @@ class HintManager(QObject): Return: The newly created label elment """ - rect = elem.geometry() - css = self.HINT_CSS.format(left=rect.x(), top=rect.y(), - config=config.instance()) + css = self._get_hint_css(elem) doc = self._frame.documentElement() # It seems impossible to create an empty QWebElement for which isNull() # is false so we can work with it. @@ -227,7 +244,13 @@ class HintManager(QObject): else: target = self._target self.set_open_target.emit(Target[target]) - point = elem.geometry().topLeft() + # FIXME this is a quick & dirty fix, we should: + # a) Have better heuristics where to click at (e.g. end of input + # fields) + # b) Check border/margin/padding to know where to click + # Hinting failed here for example: + # https://lsf.fh-worms.de/ + point = elem.geometry().topLeft() + QPoint(1, 1) scrollpos = self._frame.scrollPosition() logging.debug("Clicking on \"{}\" at {}/{} - {}/{}".format( elem.toPlainText(), point.x(), point.y(), scrollpos.x(), @@ -382,34 +405,48 @@ class HintManager(QObject): def handle_partial_key(self, keystr): """Handle a new partial keypress.""" - delete = [] + logging.debug("Handling new keystring: '{}'".format(keystr)) for (string, elems) in self._elems.items(): if string.startswith(keystr): matched = string[:len(keystr)] rest = string[len(keystr):] elems.label.setInnerXml('{}{}'.format( config.get('colors', 'hints.fg.match'), matched, rest)) + if elems.label.attribute('hidden') == 'true': + # hidden element which matches again -> unhide it + elems.label.setAttribute('hidden', 'false') + css = self._get_hint_css(elems.elem, elems.label) + elems.label.setAttribute('style', css) else: - elems.label.removeFromDocument() - delete.append(string) - for key in delete: - del self._elems[key] + # element doesn't match anymore -> hide it + elems.label.setAttribute('hidden', 'true') + css = self._get_hint_css(elems.elem, elems.label) + elems.label.setAttribute('style', css) def filter_hints(self, filterstr): """Filter displayed hints according to a text.""" - delete = [] - for (string, elems) in self._elems.items(): - if not elems.elem.toPlainText().lower().startswith(filterstr): - elems.label.removeFromDocument() - delete.append(string) - for key in delete: - del self._elems[key] - if not self._elems: + for elems in self._elems.values(): + if elems.elem.toPlainText().lower().startswith(filterstr): + if elems.label.attribute('hidden') == 'true': + # hidden element which matches again -> unhide it + elems.label.setAttribute('hidden', 'false') + css = self._get_hint_css(elems.elem, elems.label) + elems.label.setAttribute('style', css) + else: + # element doesn't match anymore -> hide it + elems.label.setAttribute('hidden', 'true') + css = self._get_hint_css(elems.elem, elems.label) + elems.label.setAttribute('style', css) + visible = {} + for k, e in self._elems.items(): + if e.label.attribute('hidden') != 'true': + visible[k] = e + if not visible: # Whoops, filtered all hints modeman.leave('hint') - elif len(self._elems) == 1 and config.get('hints', 'auto-follow'): + elif len(visible) == 1 and config.get('hints', 'auto-follow'): # unpacking gets us the first (and only) key in the dict. - self.fire(*self._elems) + self.fire(*visible) def fire(self, keystr, force=False): """Fire a completed hint. @@ -461,9 +498,7 @@ class HintManager(QObject): def on_contents_size_changed(self, _size): """Reposition hints if contents size changed.""" for elems in self._elems.values(): - rect = elems.elem.geometry() - css = self.HINT_CSS.format(left=rect.x(), top=rect.y(), - config=config.instance()) + css = self._get_hint_css(elems.elem, elems.label) elems.label.setAttribute('style', css) @pyqtSlot(str) @@ -472,9 +507,14 @@ class HintManager(QObject): if mode != 'hint': return for elem in self._elems.values(): - elem.label.removeFromDocument() - self._frame.contentsSizeChanged.disconnect( - self.on_contents_size_changed) + if not elem.label.isNull(): + elem.label.removeFromDocument() + if self._frame is not None: + # The frame which was focused in start() might not be available + # anymore, since Qt might already have deleted it (e.g. when a new + # page is loaded). + self._frame.contentsSizeChanged.disconnect( + self.on_contents_size_changed) self._elems = {} self._to_follow = None self._target = None diff --git a/qutebrowser/browser/webpage.py b/qutebrowser/browser/webpage.py index 03b65d5a6..25c677fc7 100644 --- a/qutebrowser/browser/webpage.py +++ b/qutebrowser/browser/webpage.py @@ -72,7 +72,7 @@ class BrowserPage(QWebPage): def userAgentForUrl(self, url): """Override QWebPage::userAgentForUrl to customize the user agent.""" ua = config.get('network', 'user-agent') - if not ua: + if ua is None: return super().userAgentForUrl(url) else: return ua diff --git a/qutebrowser/config/_conftypes.py b/qutebrowser/config/_conftypes.py index bf2c82412..2065f9856 100644 --- a/qutebrowser/config/_conftypes.py +++ b/qutebrowser/config/_conftypes.py @@ -155,17 +155,22 @@ class String(BaseType): Attributes: minlen: Minimum length (inclusive). maxlen: Maximum length (inclusive). + forbidden: Forbidden chars in the string. + none: Whether to convert to None for an empty string. """ typestr = 'string' - def __init__(self, minlen=None, maxlen=None, forbidden=None): + def __init__(self, minlen=None, maxlen=None, forbidden=None, none=False): self.minlen = minlen self.maxlen = maxlen self.forbidden = forbidden + self.none = none def transform(self, value): - return value.lower() + if self.none and not value: + return None + return value def validate(self, value): if self.forbidden is not None and any(c in value @@ -180,37 +185,17 @@ class String(BaseType): self.maxlen)) -class ShellCommand(String): +class List(BaseType): - """A shellcommand which is split via shlex. + """Base class for a (string-)list setting.""" - Attributes: - placeholder: If there should be a placeholder. - """ - - typestr = 'shell-cmd' - - def __init__(self, placeholder=False): - self.placeholder = placeholder - super().__init__() - - def validate(self, value): - super().validate(value) - if self.placeholder and '{}' not in value: - raise ValidationError(value, "needs to contain a {}-placeholder.") + typestr = 'string-list' def transform(self, value): - return shlex.split(value) + return value.split(',') - -class HintMode(BaseType): - - """Base class for the hints -> mode setting.""" - - typestr = 'hint-mode' - - valid_values = ValidValues(('number', "Use numeric hints."), - ('letter', "Use the chars in hints -> chars.")) + def validate(self, value): + pass class Bool(BaseType): @@ -245,18 +230,24 @@ class Int(BaseType): Attributes: minval: Minimum value (inclusive). maxval: Maximum value (inclusive). + none: Whether to accept empty values as None. """ typestr = 'int' - def __init__(self, minval=None, maxval=None): + def __init__(self, minval=None, maxval=None, none=False): self.minval = minval self.maxval = maxval + self.none = none def transform(self, value): + if self.none and not value: + return None return int(value) def validate(self, value): + if self.none and not value: + return try: intval = int(value) except ValueError: @@ -269,20 +260,21 @@ class Int(BaseType): self.maxval)) -class NoneInt(BaseType): +class IntList(List): - """Int or None.""" + """Base class for an int-list setting.""" + + typestr = 'int-list' def transform(self, value): - if value == '': - return None - else: - return super().transform(value) + vals = super().transform(value) + return map(int, vals) def validate(self, value): - if value == '': - return - super().validate(value) + try: + self.transform(value) + except ValueError: + raise ValidationError(value, "must be a list of integers!") class Float(BaseType): @@ -316,36 +308,6 @@ class Float(BaseType): self.maxval)) -class List(BaseType): - - """Base class for a (string-)list setting.""" - - typestr = 'string-list' - - def transform(self, value): - return value.split(',') - - def validate(self, value): - pass - - -class IntList(List): - - """Base class for an int-list setting.""" - - typestr = 'int-list' - - def transform(self, value): - vals = super().transform(value) - return map(int, vals) - - def validate(self, value): - try: - self.transform(value) - except ValueError: - raise ValidationError(value, "must be a list of integers!") - - class Perc(BaseType): """Percentage. @@ -355,6 +317,8 @@ class Perc(BaseType): maxval: Maximum value (inclusive). """ + typestr = 'percentage' + def __init__(self, minval=None, maxval=None): self.minval = minval self.maxval = maxval @@ -406,15 +370,6 @@ class PercList(List): raise ValidationError(value, "must be a list of percentages!") -class ZoomPerc(Perc): - - """A percentage which needs to be in the current zoom percentages.""" - - def validate(self, value): - super().validate(value) - # FIXME we should validate the percentage is in the list here. - - class PercOrInt(BaseType): """Percentage or integer. @@ -426,6 +381,8 @@ class PercOrInt(BaseType): maxint: Maximum value for integer (inclusive). """ + typestr = 'percentage-or-int' + def __init__(self, minperc=None, maxperc=None, minint=None, maxint=None): self.minperc = minperc self.maxperc = maxperc @@ -457,130 +414,6 @@ class PercOrInt(BaseType): self.maxint)) -class WebKitBytes(BaseType): - - """A size with an optional suffix. - - Attributes: - maxsize: The maximum size to be used. - - Class attributes: - SUFFIXES: A mapping of size suffixes to multiplicators. - """ - - SUFFIXES = { - 'k': 1024 ** 1, - 'm': 1024 ** 2, - 'g': 1024 ** 3, - 't': 1024 ** 4, - 'p': 1024 ** 5, - 'e': 1024 ** 6, - 'z': 1024 ** 7, - 'y': 1024 ** 8, - } - - def __init__(self, maxsize=None): - self.maxsize = maxsize - - def validate(self, value): - if value == '': - return - try: - val = self.transform(value) - except ValueError: - raise ValidationError(value, "must be a valid integer with " - "optional suffix!") - if self.maxsize is not None and val > self.maxsize: - raise ValidationError(value, "must be {} " - "maximum!".format(self.maxsize)) - - def transform(self, value): - if value == '': - return None - if any(value.lower().endswith(c) for c in self.SUFFIXES): - suffix = value[-1].lower() - val = value[:-1] - multiplicator = self.SUFFIXES[suffix] - else: - val = value - multiplicator = 1 - return int(val) * multiplicator - - -class WebKitBytesList(List): - - """A size with an optional suffix. - - Attributes: - length: The length of the list. - bytestype: The webkit bytes type. - """ - - def __init__(self, maxsize=None, length=None): - self.length = length - self.bytestype = WebKitBytes(maxsize) - - def transform(self, value): - if value == '': - return None - vals = super().transform(value) - return [self.bytestype.transform(val) for val in vals] - - def validate(self, value): - if value == '': - return - vals = super().transform(value) - for val in vals: - self.bytestype.validate(val) - if any(val is None for val in vals): - raise ValidationError(value, "all values need to be set!") - if self.length is not None and len(vals) != self.length: - raise ValidationError(value, "exactly {} values need to be " - "set!".format(self.length)) - - -class Proxy(BaseType): - - """A proxy URL or special value.""" - - valid_values = ValidValues(('system', "Use the system wide proxy."), - ('none', "Don't use any proxy")) - - PROXY_TYPES = { - 'http': QNetworkProxy.HttpProxy, - 'socks': QNetworkProxy.Socks5Proxy, - 'socks5': QNetworkProxy.Socks5Proxy, - } - - def validate(self, value): - if value in self.valid_values: - return - url = QUrl(value) - if (url.isValid() and not url.isEmpty() and - url.scheme() in self.PROXY_TYPES): - return - raise ValidationError(value, "must be a proxy URL (http://... or " - "socks://...) or system/none!") - - def complete(self): - out = [] - for val in self.valid_values: - out.append((val, self.valid_values.descriptions[val])) - out.append(('http://', 'HTTP proxy URL')) - out.append(('socks://', 'SOCKS proxy URL')) - return out - - def transform(self, value): - if value == 'system': - return None - elif value == 'none': - return QNetworkProxy(QNetworkProxy.NoProxy) - url = QUrl(value) - typ = self.PROXY_TYPES[url.scheme()] - return QNetworkProxy(typ, url.host(), url.port(), url.userName(), - url.password()) - - class Command(BaseType): """Base class for a command value with arguments.""" @@ -646,6 +479,225 @@ class Font(BaseType): pass +class Regex(BaseType): + + """A regular expression.""" + + typestr = 'regex' + + def __init__(self, flags=0): + self.flags = flags + + def validate(self, value): + try: + re.compile(value, self.flags) + except RegexError as e: + raise ValidationError(value, "must be a valid regex - " + str(e)) + + def transform(self, value): + return re.compile(value, self.flags) + + +class RegexList(List): + + """A list of regexes.""" + + typestr = 'regex-list' + + def __init__(self, flags=0): + self.flags = flags + + def transform(self, value): + vals = super().transform(value) + return [re.compile(pattern, self.flags) for pattern in vals] + + def validate(self, value): + try: + self.transform(value) + except RegexError as e: + raise ValidationError(value, "must be a list valid regexes - " + + str(e)) + + +class File(BaseType): + + """A file on the local filesystem.""" + + typestr = 'file' + + def validate(self, value): + if not os.path.isfile(value): + raise ValidationError(value, "must be a valid file!") + + +class WebKitBytes(BaseType): + + """A size with an optional suffix. + + Attributes: + maxsize: The maximum size to be used. + + Class attributes: + SUFFIXES: A mapping of size suffixes to multiplicators. + """ + + SUFFIXES = { + 'k': 1024 ** 1, + 'm': 1024 ** 2, + 'g': 1024 ** 3, + 't': 1024 ** 4, + 'p': 1024 ** 5, + 'e': 1024 ** 6, + 'z': 1024 ** 7, + 'y': 1024 ** 8, + } + + typestr = 'bytes' + + def __init__(self, maxsize=None): + self.maxsize = maxsize + + def validate(self, value): + if value == '': + return + try: + val = self.transform(value) + except ValueError: + raise ValidationError(value, "must be a valid integer with " + "optional suffix!") + if self.maxsize is not None and val > self.maxsize: + raise ValidationError(value, "must be {} " + "maximum!".format(self.maxsize)) + + def transform(self, value): + if value == '': + return None + if any(value.lower().endswith(c) for c in self.SUFFIXES): + suffix = value[-1].lower() + val = value[:-1] + multiplicator = self.SUFFIXES[suffix] + else: + val = value + multiplicator = 1 + return int(val) * multiplicator + + +class WebKitBytesList(List): + + """A size with an optional suffix. + + Attributes: + length: The length of the list. + bytestype: The webkit bytes type. + """ + + typestr = 'bytes-list' + + def __init__(self, maxsize=None, length=None): + self.length = length + self.bytestype = WebKitBytes(maxsize) + + def transform(self, value): + if value == '': + return None + vals = super().transform(value) + return [self.bytestype.transform(val) for val in vals] + + def validate(self, value): + if value == '': + return + vals = super().transform(value) + for val in vals: + self.bytestype.validate(val) + if any(val is None for val in vals): + raise ValidationError(value, "all values need to be set!") + if self.length is not None and len(vals) != self.length: + raise ValidationError(value, "exactly {} values need to be " + "set!".format(self.length)) + + +class ShellCommand(String): + + """A shellcommand which is split via shlex. + + Attributes: + placeholder: If there should be a placeholder. + """ + + typestr = 'shell-command' + + def __init__(self, placeholder=False): + self.placeholder = placeholder + super().__init__() + + def validate(self, value): + super().validate(value) + if self.placeholder and '{}' not in value: + raise ValidationError(value, "needs to contain a {}-placeholder.") + + def transform(self, value): + return shlex.split(value) + + +class ZoomPerc(Perc): + + """A percentage which needs to be in the current zoom percentages.""" + + def validate(self, value): + super().validate(value) + # FIXME we should validate the percentage is in the list here. + + +class HintMode(BaseType): + + """Base class for the hints -> mode setting.""" + + valid_values = ValidValues(('number', "Use numeric hints."), + ('letter', "Use the chars in hints -> chars.")) + + +class Proxy(BaseType): + + """A proxy URL or special value.""" + + valid_values = ValidValues(('system', "Use the system wide proxy."), + ('none', "Don't use any proxy")) + + PROXY_TYPES = { + 'http': QNetworkProxy.HttpProxy, + 'socks': QNetworkProxy.Socks5Proxy, + 'socks5': QNetworkProxy.Socks5Proxy, + } + + def validate(self, value): + if value in self.valid_values: + return + url = QUrl(value) + if (url.isValid() and not url.isEmpty() and + url.scheme() in self.PROXY_TYPES): + return + raise ValidationError(value, "must be a proxy URL (http://... or " + "socks://...) or system/none!") + + def complete(self): + out = [] + for val in self.valid_values: + out.append((val, self.valid_values.descriptions[val])) + out.append(('http://', 'HTTP proxy URL')) + out.append(('socks://', 'SOCKS proxy URL')) + return out + + def transform(self, value): + if value == 'system': + return None + elif value == 'none': + return QNetworkProxy(QNetworkProxy.NoProxy) + url = QUrl(value) + typ = self.PROXY_TYPES[url.scheme()] + return QNetworkProxy(typ, url.host(), url.port(), url.userName(), + url.password()) + + class SearchEngineName(BaseType): """A search engine name.""" @@ -673,55 +725,19 @@ class KeyBindingName(BaseType): pass -class Regex(BaseType): +class KeyBinding(Command): - """A regular expression.""" + """The command of a keybinding.""" - def __init__(self, flags=0): - self.flags = flags - - def validate(self, value): - try: - re.compile(value, self.flags) - except RegexError as e: - raise ValidationError(value, "must be a valid regex - " + str(e)) - - def transform(self, value): - return re.compile(value, self.flags) - - -class RegexList(List): - - """A list of regexes.""" - - def __init__(self, flags=0): - self.flags = flags - - def transform(self, value): - vals = super().transform(value) - return [re.compile(pattern, self.flags) for pattern in vals] - - def validate(self, value): - try: - self.transform(value) - except RegexError as e: - raise ValidationError(value, "must be a list valid regexes - " + - str(e)) - - -class File(BaseType): - - """A file on the local filesystem.""" - - def validate(self, value): - if not os.path.isfile(value): - raise ValidationError(value, "must be a valid file!") + pass class WebSettingsFile(File): """QWebSettings file which also can be none.""" + typestr = 'file' + def validate(self, value): if value == '': # empty values are okay @@ -791,10 +807,3 @@ class AcceptCookies(String): valid_values = ValidValues(('default', "Default QtWebKit behaviour"), ('never', "Don't accept cookies at all.")) - - -class KeyBinding(Command): - - """The command of a keybinding.""" - - pass diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index ae11aa84e..1edfa1434 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -271,7 +271,6 @@ class ConfigManager(QObject): Return: The value of the option. """ - logging.debug("getting {} -> {}".format(sectname, optname)) try: sect = self.sections[sectname] except KeyError: @@ -285,7 +284,6 @@ class ConfigManager(QObject): mapping = {key: val.value for key, val in sect.values.items()} newval = self._interpolation.before_get(self, sectname, optname, val.value, mapping) - logging.debug("interpolated val: {}".format(newval)) if transformed: newval = val.typ.transform(newval) return newval diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index f78308a85..d74113bf4 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -203,11 +203,11 @@ DATA = OrderedDict([ "Value to send in the DNT header."), ('accept-language', - SettingValue(types.String(), 'en-US,en'), + SettingValue(types.String(none=True), 'en-US,en'), "Value to send in the accept-language header."), ('user-agent', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "User agent to send. Empty to send the default."), ('accept-cookies', @@ -422,55 +422,55 @@ DATA = OrderedDict([ "User stylesheet to set."), ('css-media-type', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Set the CSS media type."), ('default-encoding', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Default encoding to use for websites."), ('font-family-standard', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Font family for standard fonts."), ('font-family-fixed', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Font family for fixed fonts."), ('font-family-serif', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Font family for serif fonts."), ('font-family-sans-serif', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Font family for sans-serif fonts."), ('font-family-cursive', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Font family for cursive fonts."), ('font-family-fantasy', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "Font family for fantasy fonts."), ('font-size-minimum', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "The hard minimum font size."), ('font-size-minimum-logical', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "The minimum logical font size that is applied when zooming out."), ('font-size-default', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "The default font size for regular text."), ('font-size-default-fixed', - SettingValue(types.String(), ''), + SettingValue(types.String(none=True), ''), "The default font size for fixed-pitch text."), ('maximum-pages-in-cache', - SettingValue(types.NoneInt(), ''), + SettingValue(types.Int(none=True), ''), "The default font size for fixed-pitch text."), ('object-cache-capacities', @@ -485,7 +485,7 @@ DATA = OrderedDict([ ('offline-web-application-cache-quota', SettingValue(types.WebKitBytes(maxsize=INT64_MAX), ''), - "Quota for the offline web application cache>"), + "Quota for the offline web application cache."), )), ('hints', sect.KeyValue( diff --git a/qutebrowser/keyinput/_basekeyparser.py b/qutebrowser/keyinput/_basekeyparser.py index b7b388005..3188e05ca 100644 --- a/qutebrowser/keyinput/_basekeyparser.py +++ b/qutebrowser/keyinput/_basekeyparser.py @@ -150,7 +150,8 @@ class BaseKeyParser(QObject): """ logging.debug("Got key: {} / text: '{}'".format(e.key(), e.text())) txt = e.text().strip() - if not txt: + if not txt or e.key() == Qt.Key_Backspace: + # backspace is counted as text... logging.debug("Ignoring, no text") return False @@ -172,9 +173,13 @@ class BaseKeyParser(QObject): (match, binding) = self._match_key(cmd_input) if match == self.Match.definitive: + logging.debug("Definitive match for " + "\"{}\".".format(self._keystring)) self._keystring = '' self.execute(binding, self.Type.chain, count) elif match == self.Match.ambiguous: + logging.debug("Ambigious match for " + "\"{}\".".format(self._keystring)) self._handle_ambiguous_match(binding, count) elif match == self.Match.partial: logging.debug("No match for \"{}\" (added {})".format( diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 345b43ee8..0959676e6 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -28,9 +28,11 @@ from PyQt5.QtCore import pyqtSignal, Qt import qutebrowser.utils.message as message import qutebrowser.config.config as config from qutebrowser.keyinput.keyparser import CommandKeyParser +from qutebrowser.utils.usertypes import enum STARTCHARS = ":/?" +LastPress = enum('none', 'filtertext', 'keystring') class NormalKeyParser(CommandKeyParser): @@ -69,6 +71,7 @@ class HintKeyParser(CommandKeyParser): Attributes: _filtertext: The text to filter with. + _last_press: The nature of the last keypress, a LastPress member. """ fire_hint = pyqtSignal(str) @@ -77,6 +80,7 @@ class HintKeyParser(CommandKeyParser): def __init__(self, parent=None): super().__init__(parent, supports_count=False, supports_chains=True) self._filtertext = '' + self._last_press = LastPress.none self.read_config('keybind.hint') def _handle_special_key(self, e): @@ -92,20 +96,32 @@ class HintKeyParser(CommandKeyParser): Emit: filter_hints: Emitted when filter string has changed. + keystring_updated: Emitted when keystring has been changed. """ logging.debug("Got special key {} text {}".format(e.key(), e.text())) - if config.get('hints', 'mode') != 'number': - return super()._handle_special_key(e) - elif e.key() == Qt.Key_Backspace: - if self._filtertext: + if e.key() == Qt.Key_Backspace: + logging.debug("Got backspace, mode {}, filtertext \"{}\", " + "keystring \"{}\"".format( + LastPress[self._last_press], self._filtertext, + self._keystring)) + if self._last_press == LastPress.filtertext and self._filtertext: self._filtertext = self._filtertext[:-1] self.filter_hints.emit(self._filtertext) - return True + return True + elif self._last_press == LastPress.keystring and self._keystring: + self._keystring = self._keystring[:-1] + self.keystring_updated.emit(self._keystring) + return True + else: + return super()._handle_special_key(e) + elif config.get('hints', 'mode') != 'number': + return super()._handle_special_key(e) elif not e.text(): return super()._handle_special_key(e) else: self._filtertext += e.text() self.filter_hints.emit(self._filtertext) + self._last_press = LastPress.filtertext return True def handle(self, e): @@ -120,6 +136,7 @@ class HintKeyParser(CommandKeyParser): handled = self._handle_single_key(e) if handled: self.keystring_updated.emit(self._keystring) + self._last_press = LastPress.keystring return handled return self._handle_special_key(e) diff --git a/qutebrowser/network/networkmanager.py b/qutebrowser/network/networkmanager.py index 26a5e1f0f..7932d2a80 100644 --- a/qutebrowser/network/networkmanager.py +++ b/qutebrowser/network/networkmanager.py @@ -72,8 +72,10 @@ class NetworkManager(QNetworkAccessManager): dnt = '0'.encode('ascii') req.setRawHeader('DNT'.encode('ascii'), dnt) req.setRawHeader('X-Do-Not-Track'.encode('ascii'), dnt) - req.setRawHeader('Accept-Language'.encode('ascii'), - config.get('network', 'accept-language')) + accept_language = config.get('network', 'accept-language') + if accept_language is not None: + req.setRawHeader('Accept-Language'.encode('ascii'), + accept_language.encode('ascii')) reply = super().createRequest(op, req, outgoing_data) self._requests[id(reply)] = reply reply.destroyed.connect(lambda obj: self._requests.pop(id(obj))) diff --git a/qutebrowser/test/test_urlutils.py b/qutebrowser/test/test_urlutils.py index 84d82fee2..8ee1bd418 100644 --- a/qutebrowser/test/test_urlutils.py +++ b/qutebrowser/test/test_urlutils.py @@ -137,6 +137,7 @@ class IsUrlNaiveTests(TestCase): 'foo bar', 'localhost test', 'another . test', + 'foo', ] def test_urls(self): diff --git a/qutebrowser/utils/misc.py b/qutebrowser/utils/misc.py index caecb4599..92f955874 100644 --- a/qutebrowser/utils/misc.py +++ b/qutebrowser/utils/misc.py @@ -20,6 +20,8 @@ import re import sys import shlex +import urllib.request +from urllib.parse import urljoin, urlencode from functools import reduce from pkg_resources import resource_string @@ -110,3 +112,24 @@ def shell_escape(s): # use single quotes, and put single quotes into double quotes # the string $'b is then quoted as '$'"'"'b' return "'" + s.replace("'", "'\"'\"'") + "'" + + +def pastebin(text): + """Paste the text into a pastebin and return the URL.""" + api_url = 'http://paste.the-compiler.org/api/' + data = { + 'text': text, + 'title': "qutebrowser crash", + 'name': "qutebrowser", + } + encoded_data = urlencode(data).encode('utf-8') + create_url = urljoin(api_url, 'create') + headers = { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' + } + request = urllib.request.Request(create_url, encoded_data, headers) + response = urllib.request.urlopen(request) + url = response.read().decode('utf-8').rstrip() + if not url.startswith('http'): + raise ValueError("Got unexpected response: {}".format(url)) + return url diff --git a/qutebrowser/utils/url.py b/qutebrowser/utils/url.py index f44ae3bdc..3e73c557c 100644 --- a/qutebrowser/utils/url.py +++ b/qutebrowser/utils/url.py @@ -73,11 +73,14 @@ def _is_url_naive(url): True if the url really is an URL, False otherwise. """ protocols = ['http', 'https'] + u = qurl(url) + urlstr = urlstring(url) if isinstance(url, QUrl): u = url else: u = QUrl.fromUserInput(url) - if u.scheme() in protocols: + # We don't use u here because fromUserInput appends http:// automatically. + if any(urlstr.startswith(proto) for proto in protocols): return True elif '.' in u.host(): return True diff --git a/qutebrowser/widgets/_tabbedbrowser.py b/qutebrowser/widgets/_tabbedbrowser.py index 6c9ab7cf4..08626868c 100644 --- a/qutebrowser/widgets/_tabbedbrowser.py +++ b/qutebrowser/widgets/_tabbedbrowser.py @@ -139,7 +139,8 @@ class TabbedBrowser(TabWidget): tab.open_tab.connect(self.tabopen) tab.iconChanged.connect(self.on_icon_changed) - def _tabopen(self, url, background=False): + @pyqtSlot(str, bool) + def tabopen(self, url, background=False): """Open a new tab with a given url. Inner logic for tabopen and backtabopen. @@ -217,7 +218,9 @@ class TabbedBrowser(TabWidget): return last_close = config.get('tabbar', 'last-close') if self.count() > 1: - self._url_stack.append(tab.url()) + url = tab.url() + if not url.isEmpty(): + self._url_stack.append(url) self.removeTab(idx) tab.shutdown(callback=partial(self._cb_tab_shutdown, tab)) elif last_close == 'quit': @@ -225,15 +228,16 @@ class TabbedBrowser(TabWidget): elif last_close == 'blank': tab.openurl('about:blank') - @cmdutils.register(instance='mainwindow.tabs', split=False) - def tabopen(self, url): + @cmdutils.register(instance='mainwindow.tabs', split=False, name='tabopen') + def tabopen_cmd(self, url): """Open a new tab with a given url.""" - self._tabopen(url, background=False) + self.tabopen(url, background=False) - @cmdutils.register(instance='mainwindow.tabs', split=False) - def backtabopen(self, url): + @cmdutils.register(instance='mainwindow.tabs', split=False, + name='backtabopen') + def backtabopen_cmd(self, url): """Open a new tab in background.""" - self._tabopen(url, background=True) + self.tabopen(url, background=True) @cmdutils.register(instance='mainwindow.tabs', hide=True) def tabopencur(self): diff --git a/qutebrowser/widgets/crash.py b/qutebrowser/widgets/crash.py index 1e4c6179b..cb1c05689 100644 --- a/qutebrowser/widgets/crash.py +++ b/qutebrowser/widgets/crash.py @@ -19,11 +19,15 @@ import sys import traceback +from urllib.error import URLError +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QClipboard from PyQt5.QtWidgets import (QDialog, QLabel, QTextEdit, QPushButton, - QVBoxLayout, QHBoxLayout) + QVBoxLayout, QHBoxLayout, QApplication) import qutebrowser.config.config as config +import qutebrowser.utils.misc as utils from qutebrowser.utils.version import version @@ -39,6 +43,8 @@ class CrashDialog(QDialog): _hbox: The QHboxLayout containing the buttons _btn_quit: The quit button _btn_restore: the restore button + _btn_pastebin: the pastebin button + _url: Pastebin URL QLabel. """ def __init__(self, pages, cmdhist, exc): @@ -59,7 +65,7 @@ class CrashDialog(QDialog): text = ("Argh! qutebrowser crashed unexpectedly.
" "Please review the info below to remove sensitive data and " "then submit it to " - "crash@qutebrowser.org.

") + "crash@qutebrowser.org or click 'pastebin'.

") if pages: text += ("You can click \"Restore tabs\" to attempt to reopen " "your open tabs.") @@ -68,15 +74,25 @@ class CrashDialog(QDialog): self._vbox.addWidget(self._lbl) self._txt = QTextEdit() - self._txt.setReadOnly(True) + #self._txt.setReadOnly(True) self._txt.setText(self._crash_info(pages, cmdhist, exc)) self._vbox.addWidget(self._txt) + self._url = QLabel() + self._url.setTextInteractionFlags(Qt.TextSelectableByMouse | + Qt.TextSelectableByKeyboard | + Qt.LinksAccessibleByMouse | + Qt.LinksAccessibleByKeyboard) + self._vbox.addWidget(self._url) + self._hbox = QHBoxLayout() self._hbox.addStretch() self._btn_quit = QPushButton() self._btn_quit.setText("Quit") self._btn_quit.clicked.connect(self.reject) + self._btn_pastebin = QPushButton() + self._btn_pastebin.setText("Pastebin") + self._btn_pastebin.clicked.connect(self.pastebin) self._hbox.addWidget(self._btn_quit) if pages: self._btn_restore = QPushButton() @@ -84,6 +100,7 @@ class CrashDialog(QDialog): self._btn_restore.clicked.connect(self.accept) self._btn_restore.setDefault(True) self._hbox.addWidget(self._btn_restore) + self._hbox.addWidget(self._btn_pastebin) self._vbox.addLayout(self._hbox) @@ -116,3 +133,15 @@ class CrashDialog(QDialog): chunks.append('\n'.join([h, body])) return '\n\n'.join(chunks) + + def pastebin(self): + """Paste the crash info into the pastebin.""" + try: + url = utils.pastebin(self._txt.toPlainText()) + except (URLError, ValueError) as e: + self._url.setText('Error while pasting: {}'.format(e)) + return + self._btn_pastebin.setEnabled(False) + self._url.setText("URL copied to clipboard: " + "{}".format(url, url)) + QApplication.clipboard().setText(url, QClipboard.Clipboard) diff --git a/qutebrowser/widgets/webview.py b/qutebrowser/widgets/webview.py index 8d4b7d830..6ccdd7fce 100644 --- a/qutebrowser/widgets/webview.py +++ b/qutebrowser/widgets/webview.py @@ -208,12 +208,6 @@ class WebView(QWebView): self._destroyed[self] = False self.destroyed.connect(functools.partial(self._on_destroyed, self)) self.deleteLater() - - netman = QApplication.instance().networkmanager - self._destroyed[netman] = False - netman.abort_requests() - netman.destroyed.connect(functools.partial(self._on_destroyed, netman)) - netman.deleteLater() logging.debug("Tab shutdown scheduled") @pyqtSlot(str) @@ -269,7 +263,9 @@ class WebView(QWebView): Args: target: A string to set self._force_open_target to. """ - self._force_open_target = getattr(Target, target) + t = getattr(Target, target) + logging.debug("Setting force target to {}/{}".format(target, t)) + self._force_open_target = t def paintEvent(self, e): """Extend paintEvent to emit a signal if the scroll position changed. @@ -338,14 +334,16 @@ class WebView(QWebView): self._open_target = self._force_open_target self._force_open_target = None logging.debug("Setting force target: {}".format( - self._open_target)) + Target[self._open_target])) elif (e.button() == Qt.MidButton or e.modifiers() & Qt.ControlModifier): if config.get('general', 'background-tabs'): self._open_target = Target.bgtab else: self._open_target = Target.tab - logging.debug("Setting target: {}".format(self._open_target)) + logging.debug("Middle click, setting target: {}".format( + Target[self._open_target])) else: self._open_target = Target.normal + logging.debug("Normal click, setting normal target") return super().mousePressEvent(e) diff --git a/scripts/run_checks.py b/scripts/run_checks.py index 519c59da2..a00e2967c 100755 --- a/scripts/run_checks.py +++ b/scripts/run_checks.py @@ -29,6 +29,7 @@ import sys import subprocess import os import os.path +import unittest from collections import OrderedDict try: @@ -108,6 +109,13 @@ def check_pep257(args=None): print() +def check_unittest(): + print("====== unittest ======") + suite = unittest.TestLoader().discover('.') + result = unittest.TextTestRunner().run(suite) + print() + status['unittest'] = result.wasSuccessful() + def check_line(): """Run _check_file over a filetree.""" print("====== line ======") @@ -198,6 +206,7 @@ def _get_args(checker): if do_check_257: check_pep257(_get_args('pep257')) +check_unittest() for checker in ['pylint', 'flake8', 'pyroma']: # FIXME what the hell is the flake8 exit status? run(checker, _get_args(checker))