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
import qutebrowser.resources import qutebrowser.resources
from qutebrowser.completion import completiondelegate
from qutebrowser.completion.models import miscmodels from qutebrowser.completion.models import miscmodels
from qutebrowser.commands import cmdutils, runners, cmdexc from qutebrowser.commands import cmdutils, runners, cmdexc
from qutebrowser.config import config, websettings, configfiles, configinit from qutebrowser.config import config, websettings, configfiles, configinit
@ -452,9 +451,6 @@ def _init_modules(args, crash_handler):
pre_text='Error initializing SQL') pre_text='Error initializing SQL')
sys.exit(usertypes.Exit.err_init) sys.exit(usertypes.Exit.err_init)
log.init.debug("Initializing completion...")
completiondelegate.init()
log.init.debug("Initializing command history...") log.init.debug("Initializing command history...")
cmdhistory.init() cmdhistory.init()

View File

@ -28,13 +28,27 @@ import html
from PyQt5.QtWidgets import QStyle, QStyleOptionViewItem, QStyledItemDelegate from PyQt5.QtWidgets import QStyle, QStyleOptionViewItem, QStyledItemDelegate
from PyQt5.QtCore import QRectF, QSize, Qt from PyQt5.QtCore import QRectF, QSize, Qt
from PyQt5.QtGui import (QIcon, QPalette, QTextDocument, QTextOption, from PyQt5.QtGui import (QIcon, QPalette, QTextDocument, QTextOption,
QAbstractTextDocumentLayout) QAbstractTextDocumentLayout, QSyntaxHighlighter,
QTextCharFormat)
from qutebrowser.config import config 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): class CompletionItemDelegate(QStyledItemDelegate):
@ -194,27 +208,15 @@ class CompletionItemDelegate(QStyledItemDelegate):
self._doc.setDefaultTextOption(text_option) self._doc.setDefaultTextOption(text_option)
self._doc.setDocumentMargin(2) self._doc.setDocumentMargin(2)
assert _cached_stylesheet is not None
self._doc.setDefaultStyleSheet(_cached_stylesheet)
if index.parent().isValid(): if index.parent().isValid():
view = self.parent() view = self.parent()
pattern = view.pattern pattern = view.pattern
columns_to_filter = index.model().columns_to_filter(index) columns_to_filter = index.model().columns_to_filter(index)
self._doc.setPlainText(self._opt.text)
if index.column() in columns_to_filter and pattern: if index.column() in columns_to_filter and pattern:
pat = '({})'.format(re.escape(pattern).replace(r'\ ', r'|')) pat = re.escape(pattern).replace(r'\ ', r'|')
parts = re.split(pat, self._opt.text, flags=re.IGNORECASE) _Highlighter(self._doc, pat,
fmt = '<span class="highlight">{}</span>' config.val.colors.completion.match.fg)
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)
else: else:
self._doc.setHtml( self._doc.setHtml(
'<span style="font: {};">{}</span>'.format( '<span style="font: {};">{}</span>'.format(
@ -289,24 +291,3 @@ class CompletionItemDelegate(QStyledItemDelegate):
self._draw_focus_rect() self._draw_focus_rect()
self._painter.restore() 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 from unittest import mock
import pytest import pytest
from PyQt5.QtCore import QModelIndex, QObject from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPainter from PyQt5.QtGui import QTextDocument
from qutebrowser.completion import completiondelegate from qutebrowser.completion import completiondelegate
@pytest.fixture @pytest.mark.parametrize('pat,txt,segments', [
def painter(): ('foo', 'foo', [(0, 3)]),
return mock.Mock(spec=QPainter) ('foo', 'foobar', [(0, 3)]),
('foo', 'barfoo', [(3, 3)]),
('foo', 'barfoobaz', [(3, 3)]),
def _qt_mock(klass, mocker): ('foo', 'barfoobazfoo', [(3, 3), (9, 3)]),
m = mocker.patch.object(completiondelegate, klass, autospec=True) ('foo', 'foofoo', [(0, 3), (3, 3)]),
return m ('a|b', 'cadb', [(1, 1), (3, 1)]),
('foo', '<foo>', [(1, 3)]),
('<a>', "<a>bc", [(0, 3)]),
@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'),
# https://github.com/qutebrowser/qutebrowser/issues/4199 # https://github.com/qutebrowser/qutebrowser/issues/4199
('foo', "'foo'", "'{foo}'"), ('foo', "'foo'", [(1, 3)]),
('x', "'x'", "'{x}'"), ('x', "'x'", [(1, 1)]),
('lt', "<lt", "&lt;{lt}"), ('lt', "<lt", [(1, 2)]),
]) ])
def test_paint(delegate, painter, view, mock_style_option, mock_text_document, def test_highlight(pat, txt, segments):
pat, txt_in, txt_out): doc = QTextDocument(txt)
view.pattern = pat highlighter = completiondelegate._Highlighter(doc, pat, Qt.red)
mock_style_option().text = txt_in highlighter.setFormat = mock.Mock()
index = mock.Mock(spec=QModelIndex) highlighter.highlightBlock(txt)
index.column.return_value = 0 highlighter.setFormat.assert_has_calls([
index.model.return_value.columns_to_filter.return_value = [0] mock.call(s[0], s[1], mock.ANY) for s in segments
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)