Repository: qutebrowser/qutebrowser

Issue:
Apostrophes sometimes show as HTML escapes in the completion
When doing `python3 -m qutebrowser --temp-basedir -s aliases '{"x": "session-load -c custom-sess"}'` and doing `:x`, the apostrophes in the description show up as escapes (but that isn't always the case):

![image](https://user-images.githubusercontent.com/625793/45225231-584f7c00-b2bc-11e8-9de5-d4f83a00296d.png)

Maybe Qt interprets it as plaintext instead of rich text?

cc @rcorre if you feel like taking a look


Discussion:
Ah, looks like we're first running `html.escape` over the text, and then replacing highlighted parts, causing us to replace the `x` inside the HTML escape.

Resolving patch:
diff --git a/qutebrowser/app.py b/qutebrowser/app.py
--- a/qutebrowser/app.py
+++ b/qutebrowser/app.py
@@ -59,7 +59,6 @@
 
 import qutebrowser
 import qutebrowser.resources
-from qutebrowser.completion import completiondelegate
 from qutebrowser.completion.models import miscmodels
 from qutebrowser.commands import cmdutils, runners, cmdexc
 from qutebrowser.config import config, websettings, configfiles, configinit
@@ -452,9 +451,6 @@ def _init_modules(args, crash_handler):
                                pre_text='Error initializing SQL')
         sys.exit(usertypes.Exit.err_init)
 
-    log.init.debug("Initializing completion...")
-    completiondelegate.init()
-
     log.init.debug("Initializing command history...")
     cmdhistory.init()
 
diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py
--- a/qutebrowser/completion/completiondelegate.py
+++ b/qutebrowser/completion/completiondelegate.py
@@ -28,13 +28,27 @@
 from PyQt5.QtWidgets import QStyle, QStyleOptionViewItem, QStyledItemDelegate
 from PyQt5.QtCore import QRectF, QSize, Qt
 from PyQt5.QtGui import (QIcon, QPalette, QTextDocument, QTextOption,
-                         QAbstractTextDocumentLayout)
+                         QAbstractTextDocumentLayout, QSyntaxHighlighter,
+                         QTextCharFormat)
 
 from qutebrowser.config import config
-from qutebrowser.utils import qtutils, jinja
+from qutebrowser.utils import qtutils
 
 
-_cached_stylesheet = None
+class _Highlighter(QSyntaxHighlighter):
+
+    def __init__(self, doc, pattern, color):
+        super().__init__(doc)
+        self._format = QTextCharFormat()
+        self._format.setForeground(color)
+        self._pattern = pattern
+
+    def highlightBlock(self, text):
+        """Override highlightBlock for custom highlighting."""
+        for match in re.finditer(self._pattern, text):
+            start, end = match.span()
+            length = end - start
+            self.setFormat(start, length, self._format)
 
 
 class CompletionItemDelegate(QStyledItemDelegate):
@@ -194,21 +208,15 @@ def _get_textdoc(self, index):
         self._doc.setDefaultTextOption(text_option)
         self._doc.setDocumentMargin(2)
 
-        assert _cached_stylesheet is not None
-        self._doc.setDefaultStyleSheet(_cached_stylesheet)
-
         if index.parent().isValid():
             view = self.parent()
             pattern = view.pattern
             columns_to_filter = index.model().columns_to_filter(index)
+            self._doc.setPlainText(self._opt.text)
             if index.column() in columns_to_filter and pattern:
-                repl = r'<span class="highlight">\g<0></span>'
-                pat = html.escape(re.escape(pattern)).replace(r'\ ', r'|')
-                txt = html.escape(self._opt.text)
-                text = re.sub(pat, repl, txt, flags=re.IGNORECASE)
-                self._doc.setHtml(text)
-            else:
-                self._doc.setPlainText(self._opt.text)
+                pat = re.escape(pattern).replace(r'\ ', r'|')
+                _Highlighter(self._doc, pat,
+                             config.val.colors.completion.match.fg)
         else:
             self._doc.setHtml(
                 '<span style="font: {};">{}</span>'.format(
@@ -283,24 +291,3 @@ def paint(self, painter, option, index):
         self._draw_focus_rect()
 
         self._painter.restore()
-
-
-@config.change_filter('colors.completion.match.fg', function=True)
-def _update_stylesheet():
-    """Update the cached stylesheet."""
-    stylesheet = """
-        .highlight {
-            color: {{ conf.colors.completion.match.fg }};
-        }
-    """
-    with jinja.environment.no_autoescape():
-        template = jinja.environment.from_string(stylesheet)
-
-    global _cached_stylesheet
-    _cached_stylesheet = template.render(conf=config.val)
-
-
-def init():
-    """Initialize the cached stylesheet."""
-    _update_stylesheet()
-    config.instance.changed.connect(_update_stylesheet)
diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py
--- a/qutebrowser/config/configtypes.py
+++ b/qutebrowser/config/configtypes.py
@@ -905,11 +905,43 @@ class QtColor(BaseType):
     * transparent (no color)
     """
 
+    def _parse_value(self, val):
+        try:
+            return int(val)
+        except ValueError:
+            pass
+
+        mult = 255.0
+        if val.endswith('%'):
+            val = val[:-1]
+            mult = 255.0 / 100
+
+        try:
+            return int(float(val) * mult)
+        except ValueError:
+            raise configexc.ValidationError(val, "must be a valid color value")
+
     def to_py(self, value):
         self._basic_py_validation(value, str)
         if not value:
             return None
 
+        if '(' in value and value.endswith(')'):
+            openparen = value.index('(')
+            kind = value[:openparen]
+            vals = value[openparen+1:-1].split(',')
+            vals = [self._parse_value(v) for v in vals]
+            if kind == 'rgba' and len(vals) == 4:
+                return QColor.fromRgb(*vals)
+            elif kind == 'rgb' and len(vals) == 3:
+                return QColor.fromRgb(*vals)
+            elif kind == 'hsva' and len(vals) == 4:
+                return QColor.fromHsv(*vals)
+            elif kind == 'hsv' and len(vals) == 3:
+                return QColor.fromHsv(*vals)
+            else:
+                raise configexc.ValidationError(value, "must be a valid color")
+
         color = QColor(value)
         if color.isValid():
             return color


Test patch:
diff --git a/tests/unit/completion/test_completiondelegate.py b/tests/unit/completion/test_completiondelegate.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/completion/test_completiondelegate.py
@@ -0,0 +1,51 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2018 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
+#
+# 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/>.
+from unittest import mock
+
+import pytest
+from PyQt5.QtCore import Qt
+from PyQt5.QtGui import QTextDocument
+
+from qutebrowser.completion import completiondelegate
+
+
+@pytest.mark.parametrize('pat,txt,segments', [
+    ('foo', 'foo', [(0, 3)]),
+    ('foo', 'foobar', [(0, 3)]),
+    ('foo', 'barfoo', [(3, 3)]),
+    ('foo', 'barfoobaz', [(3, 3)]),
+    ('foo', 'barfoobazfoo', [(3, 3), (9, 3)]),
+    ('foo', 'foofoo', [(0, 3), (3, 3)]),
+    ('a|b', 'cadb', [(1, 1), (3, 1)]),
+    ('foo', '<foo>', [(1, 3)]),
+    ('<a>', "<a>bc", [(0, 3)]),
+
+    # https://github.com/qutebrowser/qutebrowser/issues/4199
+    ('foo', "'foo'", [(1, 3)]),
+    ('x', "'x'", [(1, 1)]),
+    ('lt', "<lt", [(1, 2)]),
+])
+def test_highlight(pat, txt, segments):
+    doc = QTextDocument(txt)
+    highlighter = completiondelegate._Highlighter(doc, pat, Qt.red)
+    highlighter.setFormat = mock.Mock()
+    highlighter.highlightBlock(txt)
+    highlighter.setFormat.assert_has_calls([
+        mock.call(s[0], s[1], mock.ANY) for s in segments
+    ])
diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py
--- a/tests/unit/config/test_configtypes.py
+++ b/tests/unit/config/test_configtypes.py
@@ -21,7 +21,6 @@
 import re
 import json
 import math
-import itertools
 import warnings
 import inspect
 import functools
@@ -1222,87 +1221,93 @@ def test_complete(self, patch_cmdutils, klass):
         assert ('cmd2', "desc 2") in items
 
 
-class ColorTests:
+class TestQtColor:
 
-    """Generator for tests for TestColors."""
+    """Test QtColor."""
 
-    TYPES = [configtypes.QtColor, configtypes.QssColor]
-
-    TESTS = [
-        ('#123', TYPES),
-        ('#112233', TYPES),
-        ('#111222333', TYPES),
-        ('#111122223333', TYPES),
-        ('red', TYPES),
-
-        ('#00000G', []),
-        ('#123456789ABCD', []),
-        ('#12', []),
-        ('foobar', []),
-        ('42', []),
-        ('foo(1, 2, 3)', []),
-        ('rgb(1, 2, 3', []),
-
-        ('rgb(0, 0, 0)', [configtypes.QssColor]),
-        ('rgb(0,0,0)', [configtypes.QssColor]),
-
-        ('rgba(255, 255, 255, 1.0)', [configtypes.QssColor]),
-        ('hsv(10%,10%,10%)', [configtypes.QssColor]),
-
-        ('qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 white, '
-         'stop: 0.4 gray, stop:1 green)', [configtypes.QssColor]),
-        ('qconicalgradient(cx:0.5, cy:0.5, angle:30, stop:0 white, '
-         'stop:1 #00FF00)', [configtypes.QssColor]),
-        ('qradialgradient(cx:0, cy:0, radius: 1, fx:0.5, fy:0.5, '
-         'stop:0 white, stop:1 green)', [configtypes.QssColor]),
-    ]
-
-    COMBINATIONS = list(itertools.product(TESTS, TYPES))
-
-    def __init__(self):
-        self.valid = list(self._generate_valid())
-        self.invalid = list(self._generate_invalid())
+    @pytest.mark.parametrize('val, expected', [
+        ('#123', QColor('#123')),
+        ('#112233', QColor('#112233')),
+        ('#111222333', QColor('#111222333')),
+        ('#111122223333', QColor('#111122223333')),
+        ('red', QColor('red')),
+
+        ('rgb(0, 0, 0)', QColor.fromRgb(0, 0, 0)),
+        ('rgb(0,0,0)', QColor.fromRgb(0, 0, 0)),
+
+        ('rgba(255, 255, 255, 1.0)', QColor.fromRgb(255, 255, 255, 255)),
+
+        # this should be (36, 25, 25) as hue goes to 359
+        # however this is consistent with Qt's CSS parser
+        # https://bugreports.qt.io/browse/QTBUG-70897
+        ('hsv(10%,10%,10%)', QColor.fromHsv(25, 25, 25)),
+        ('hsva(10%,20%,30%,40%)', QColor.fromHsv(25, 51, 76, 102)),
+    ])
+    def test_valid(self, val, expected):
+        assert configtypes.QtColor().to_py(val) == expected
 
-    def _generate_valid(self):
-        for (val, valid_classes), klass in self.COMBINATIONS:
-            if klass in valid_classes:
-                yield klass, val
+    @pytest.mark.parametrize('val', [
+        '#00000G',
+        '#123456789ABCD',
+        '#12',
+        'foobar',
+        '42',
+        'foo(1, 2, 3)',
+        'rgb(1, 2, 3',
+        'rgb)',
+        'rgb(1, 2, 3))',
+        'rgb((1, 2, 3)',
+        'rgb()',
+        'rgb(1, 2, 3, 4)',
+        'rgba(1, 2, 3)',
+        'rgb(10%%, 0, 0)',
+    ])
+    def test_invalid(self, val):
+        with pytest.raises(configexc.ValidationError):
+            configtypes.QtColor().to_py(val)
 
-    def _generate_invalid(self):
-        for (val, valid_classes), klass in self.COMBINATIONS:
-            if klass not in valid_classes:
-                yield klass, val
 
+class TestQssColor:
 
-class TestColors:
+    """Test QssColor."""
 
-    """Test QtColor/QssColor."""
+    @pytest.mark.parametrize('val', [
+        '#123',
+        '#112233',
+        '#111222333',
+        '#111122223333',
+        'red',
 
-    TESTS = ColorTests()
+        'rgb(0, 0, 0)',
+        'rgb(0,0,0)',
 
-    @pytest.fixture(params=ColorTests.TYPES)
-    def klass_fixt(self, request):
-        """Fixture which provides all ColorTests classes.
+        'rgba(255, 255, 255, 1.0)',
+        'hsv(10%,10%,10%)',
 
-        Named klass_fix so it has a different name from the parametrized klass,
-        see https://github.com/pytest-dev/pytest/issues/979.
-        """
-        return request.param
+        'qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 white, '
+        'stop: 0.4 gray, stop:1 green)',
 
-    def test_test_generator(self):
-        """Some sanity checks for ColorTests."""
-        assert self.TESTS.valid
-        assert self.TESTS.invalid
+        'qconicalgradient(cx:0.5, cy:0.5, angle:30, stop:0 white, '
+        'stop:1 #00FF00)',
 
-    @pytest.mark.parametrize('klass, val', TESTS.valid)
-    def test_to_py_valid(self, klass, val):
-        expected = QColor(val) if klass is configtypes.QtColor else val
-        assert klass().to_py(val) == expected
+        'qradialgradient(cx:0, cy:0, radius: 1, fx:0.5, fy:0.5, '
+        'stop:0 white, stop:1 green)',
+    ])
+    def test_valid(self, val):
+        assert configtypes.QssColor().to_py(val) == val
 
-    @pytest.mark.parametrize('klass, val', TESTS.invalid)
-    def test_to_py_invalid(self, klass, val):
+    @pytest.mark.parametrize('val', [
+        '#00000G',
+        '#123456789ABCD',
+        '#12',
+        'foobar',
+        '42',
+        'foo(1, 2, 3)',
+        'rgb(1, 2, 3',
+    ])
+    def test_invalid(self, val):
         with pytest.raises(configexc.ValidationError):
-            klass().to_py(val)
+            configtypes.QssColor().to_py(val)
 
 
 @attr.s