Use QSyntaxHighlighter for completion.

This is a more "Qt" way of highlighting syntax, and works around the
problems of #4199 without resorting to complicated html escaping.

The tests are more straightforward with less mocking, but do involve
testing a private class.
This commit is contained in:
Ryan Roden-Corrent 2018-09-22 11:58:35 -04:00
parent 2eacf4bd94
commit 0fed563a02
No known key found for this signature in database
GPG Key ID: 4E5072F68872BC04
3 changed files with 44 additions and 116 deletions

View File

@ -59,7 +59,6 @@ except ImportError:
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()

View File

@ -28,13 +28,27 @@ import html
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,27 +208,15 @@ class CompletionItemDelegate(QStyledItemDelegate):
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:
pat = '({})'.format(re.escape(pattern).replace(r'\ ', r'|'))
parts = re.split(pat, self._opt.text, flags=re.IGNORECASE)
fmt = '<span class="highlight">{}</span>'
escape = lambda s: html.escape(s, quote=False)
highlight = lambda s: fmt.format(escape(s))
# matches are at every odd index
text = ''.join([
highlight(s) if i % 2 == 1 else escape(s)
for i, s in enumerate(parts)
])
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(
@ -289,24 +291,3 @@ class CompletionItemDelegate(QStyledItemDelegate):
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)

View File

@ -19,82 +19,33 @@
from unittest import mock
import pytest
from PyQt5.QtCore import QModelIndex, QObject
from PyQt5.QtGui import QPainter
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QTextDocument
from qutebrowser.completion import completiondelegate
@pytest.fixture
def painter():
return mock.Mock(spec=QPainter)
def _qt_mock(klass, mocker):
m = mocker.patch.object(completiondelegate, klass, autospec=True)
return m
@pytest.fixture
def mock_style_option(mocker):
return _qt_mock('QStyleOptionViewItem', mocker)
@pytest.fixture
def mock_text_document(mocker):
return _qt_mock('QTextDocument', mocker)
@pytest.fixture
def view():
class FakeView(QObject):
def __init__(self):
super().__init__()
self.pattern = None
return FakeView()
@pytest.fixture
def delegate(mock_style_option, mock_text_document, config_stub, mocker, view):
_qt_mock('QStyle', mocker)
_qt_mock('QTextOption', mocker)
_qt_mock('QAbstractTextDocumentLayout', mocker)
completiondelegate._cached_stylesheet = ''
delegate = completiondelegate.CompletionItemDelegate(parent=view)
delegate.initStyleOption = mock.Mock()
return delegate
@pytest.mark.parametrize('pat,txt_in,txt_out', [
# { and } represent the open/close html tags for highlighting
('foo', 'foo', '{foo}'),
('foo', 'foobar', '{foo}bar'),
('foo', 'barfoo', 'bar{foo}'),
('foo', 'barfoobaz', 'bar{foo}baz'),
('foo', 'barfoobazfoo', 'bar{foo}baz{foo}'),
('foo', 'foofoo', '{foo}{foo}'),
('a b', 'cadb', 'c{a}d{b}'),
('foo', '<foo>', '&lt;{foo}&gt;'),
('<a>', "<a>bc", '{&lt;a&gt;}bc'),
@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'", "'{foo}'"),
('x', "'x'", "'{x}'"),
('lt', "<lt", "&lt;{lt}"),
('foo', "'foo'", [(1, 3)]),
('x', "'x'", [(1, 1)]),
('lt', "<lt", [(1, 2)]),
])
def test_paint(delegate, painter, view, mock_style_option, mock_text_document,
pat, txt_in, txt_out):
view.pattern = pat
mock_style_option().text = txt_in
index = mock.Mock(spec=QModelIndex)
index.column.return_value = 0
index.model.return_value.columns_to_filter.return_value = [0]
opt = mock_style_option()
delegate.paint(painter, opt, index)
expected = txt_out.replace(
'{', '<span class="highlight">'
).replace(
'}', '</span>'
)
mock_text_document().setHtml.assert_called_once_with(expected)
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
])