diff --git a/docs/utils.md b/docs/utils.md new file mode 100644 index 0000000..c43efdc --- /dev/null +++ b/docs/utils.md @@ -0,0 +1,10 @@ +# Utils + +## Code highlighting + +`superqt` provides a code highlighter subclass of `QSyntaxHighlighter` +that can be used to highlight code in a QTextEdit. + +Code lexer and available styles are from [`pygments`](https://pygments.org/) python library +List of available languages are available [here](https://pygments.org/languages/). +List of available styles are available [here](https://pygments.org/styles/). diff --git a/examples/code_highlight.py b/examples/code_highlight.py new file mode 100644 index 0000000..a601af4 --- /dev/null +++ b/examples/code_highlight.py @@ -0,0 +1,32 @@ +from PyQt5.QtGui import QColor, QPalette +from qtpy.QtWidgets import QApplication, QTextEdit + +from superqt.utils import CodeSyntaxHighlight + +app = QApplication([]) + +text_area = QTextEdit() + +highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai") + +palette = text_area.palette() +palette.setColor(QPalette.Base, QColor(highlight.background_color)) +text_area.setPalette(palette) +text_area.setText( + """from argparse import ArgumentParser + +def main(): + parser = ArgumentParser() + parser.add_argument("name", help="Your name") + args = parser.parse_args() + print(f"Hello {args.name}") + + +if __name__ == "__main__": + main() +""" +) + +text_area.show() + +app.exec_() diff --git a/setup.cfg b/setup.cfg index 5a27331..7ba0f0d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ project_urls = packages = find: install_requires = packaging + pygments>=2.4.0 qtpy>=1.1.0 typing-extensions>=3.10.0.0 python_requires = >=3.7 diff --git a/src/superqt/utils/__init__.py b/src/superqt/utils/__init__.py index 9b5594c..de578c5 100644 --- a/src/superqt/utils/__init__.py +++ b/src/superqt/utils/__init__.py @@ -1,4 +1,5 @@ __all__ = ( + "CodeSyntaxHighlight", "create_worker", "ensure_main_thread", "ensure_object_thread", @@ -15,7 +16,7 @@ __all__ = ( "WorkerBase", ) - +from ._code_syntax_highlight import CodeSyntaxHighlight from ._ensure_thread import ensure_main_thread, ensure_object_thread from ._message_handler import QMessageHandler from ._misc import signals_blocked diff --git a/src/superqt/utils/_code_syntax_highlight.py b/src/superqt/utils/_code_syntax_highlight.py new file mode 100644 index 0000000..47d4fe0 --- /dev/null +++ b/src/superqt/utils/_code_syntax_highlight.py @@ -0,0 +1,93 @@ +from itertools import takewhile + +from pygments import highlight +from pygments.formatter import Formatter +from pygments.lexers import find_lexer_class, get_lexer_by_name +from pygments.util import ClassNotFound +from qtpy import QtGui + +# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py (MIT license) and +# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter + + +def get_text_char_format(style): + """ + Return a QTextCharFormat with the given attributes. + + https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter + """ + + text_char_format = QtGui.QTextCharFormat() + text_char_format.setFontFamily("monospace") + if style.get("color"): + text_char_format.setForeground(QtGui.QColor(f"#{style['color']}")) + + if style.get("bgcolor"): + text_char_format.setBackground(QtGui.QColor(style["bgcolor"])) + + if style.get("bold"): + text_char_format.setFontWeight(QtGui.QFont.Bold) + if style.get("italic"): + text_char_format.setFontItalic(True) + if style.get("underline"): + text_char_format.setFontUnderline(True) + + # TODO find if it is possible to support border style. + + return text_char_format + + +class QFormatter(Formatter): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.data = [] + self._style = {name: get_text_char_format(style) for name, style in self.style} + + def format(self, tokensource, outfile): + """ + `outfile` is argument from parent class, but + in Qt we do not produce string output, but QTextCharFormat, so it needs to be + collected using `self.data`. + """ + self.data = [] + + for token, value in tokensource: + self.data.extend( + [ + self._style[token], + ] + * len(value) + ) + + +class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter): + def __init__(self, parent, lang, theme): + super().__init__(parent) + self.formatter = QFormatter(style=theme) + try: + self.lexer = get_lexer_by_name(lang) + except ClassNotFound: + self.lexer = find_lexer_class(lang)() + + @property + def background_color(self): + return self.formatter.style.background_color + + def highlightBlock(self, text): + cb = self.currentBlock() + p = cb.position() + text_ = self.document().toPlainText() + "\n" + highlight(text_, self.lexer, self.formatter) + + enters = sum(1 for _ in takewhile(lambda x: x == "\n", text_)) + # pygments lexer ignore leading empty lines, so we need to do correction + # here calculating the number of empty lines. + + # dirty, dirty hack + # The core problem is that pygemnts by default use string streams, + # that will not handle QTextCharFormat, so wee need use `data` property to work around this. + for i in range(len(text)): + try: + self.setFormat(i, 1, self.formatter.data[p + i - enters]) + except IndexError: # pragma: no cover + pass diff --git a/tests/test_code_highlight.py b/tests/test_code_highlight.py new file mode 100644 index 0000000..d097e77 --- /dev/null +++ b/tests/test_code_highlight.py @@ -0,0 +1,19 @@ +from qtpy.QtWidgets import QTextEdit + +from superqt.utils import CodeSyntaxHighlight + + +def test_code_highlight(qtbot): + widget = QTextEdit() + qtbot.addWidget(widget) + code_highlight = CodeSyntaxHighlight(widget, "python", "default") + assert code_highlight.background_color == "#f8f8f8" + widget.setText("from argparse import ArgumentParser") + + +def test_code_highlight_by_name(qtbot): + widget = QTextEdit() + qtbot.addWidget(widget) + code_highlight = CodeSyntaxHighlight(widget, "Python Traceback", "monokai") + assert code_highlight.background_color == "#272822" + widget.setText("from argparse import ArgumentParser")