From 6522bfc03a10e6456b4952b61bbd586331882bee Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 12:05:08 -0500 Subject: [PATCH 1/6] feat: improve syntax highlighter --- src/superqt/utils/_code_syntax_highlight.py | 261 ++++++++++++++++---- 1 file changed, 218 insertions(+), 43 deletions(-) diff --git a/src/superqt/utils/_code_syntax_highlight.py b/src/superqt/utils/_code_syntax_highlight.py index 73f2ef15..a1ce66c6 100644 --- a/src/superqt/utils/_code_syntax_highlight.py +++ b/src/superqt/utils/_code_syntax_highlight.py @@ -1,81 +1,256 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import pygments.token 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 +from qtpy.QtGui import ( + QColor, + QFont, + QPalette, + QSyntaxHighlighter, + QTextCharFormat, + QTextDocument, +) -# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py -# (MIT license) and -# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from typing import Literal, TypeAlias, TypedDict, Unpack + + import pygments.style + from pygments.style import _StyleDict + from pygments.token import _TokenType + from qtpy.QtCore import QObject + + class SupportsDocumentAndPalette(QObject): + def document(self) -> QTextDocument | None: ... + def palette(self) -> QPalette: ... + def setPalette(self, palette: QPalette) -> None: ... + KnownStyle: TypeAlias = Literal[ + "abap", + "algol", + "algol_nu", + "arduino", + "autumn", + "bw", + "borland", + "coffee", + "colorful", + "default", + "dracula", + "emacs", + "friendly_grayscale", + "friendly", + "fruity", + "github-dark", + "gruvbox-dark", + "gruvbox-light", + "igor", + "inkpot", + "lightbulb", + "lilypond", + "lovelace", + "manni", + "material", + "monokai", + "murphy", + "native", + "nord-darker", + "nord", + "one-dark", + "paraiso-dark", + "paraiso-light", + "pastie", + "perldoc", + "rainbow_dash", + "rrt", + "sas", + "solarized-dark", + "solarized-light", + "staroffice", + "stata-dark", + "stata-light", + "tango", + "trac", + "vim", + "vs", + "xcode", + "zenburn", + ] -def get_text_char_format( - style: dict[str, QtGui.QTextCharFormat], -) -> QtGui.QTextCharFormat: - text_char_format = QtGui.QTextCharFormat() - if hasattr(text_char_format, "setFontFamilies"): - text_char_format.setFontFamilies(["monospace"]) - else: - text_char_format.setFontFamily("monospace") - if style.get("color"): - text_char_format.setForeground(QtGui.QColor(f"#{style['color']}")) + class FormatterKwargs(TypedDict, total=False): + style: KnownStyle | str + full: bool + title: str + encoding: str + outencoding: str - if style.get("bgcolor"): - text_char_format.setBackground(QtGui.QColor(style["bgcolor"])) +MONO_FAMILIES = [ + "Menlo", + "Courier New", + "Courier", + "Monaco", + "Consolas", + "Andale Mono", + "Source Code Pro", + "monospace", +] + + +# 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: _StyleDict) -> QTextCharFormat: + """Return a QTextCharFormat object based on the given Pygments `_StyleDict`. + + style will likely have these keys: + - color: str | None + - bold: bool + - italic: bool + - underline: bool + - bgcolor: str | None + - border: str | None + - roman: bool | None + - sans: bool | None + - mono: bool | None + - ansicolor: str | None + - bgansicolor: str | None + """ + text_char_format = QTextCharFormat() + if style.get("mono"): + text_char_format.setFontFamilies(MONO_FAMILIES) + if color := style.get("color"): + text_char_format.setForeground(QColor(f"#{color}")) + if bgcolor := style.get("bgcolor"): + text_char_format.setBackground(QColor(bgcolor)) if style.get("bold"): - text_char_format.setFontWeight(QtGui.QFont.Bold) + text_char_format.setFontWeight(QFont.Weight.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. - + # if style.get("border"): + # ... return text_char_format class QFormatter(Formatter): - def __init__(self, **kwargs): + def __init__(self, **kwargs: Unpack[FormatterKwargs]) -> None: super().__init__(**kwargs) - self.data: list[QtGui.QTextCharFormat] = [] - self._style = {name: get_text_char_format(style) for name, style in self.style} + self.data: list[QTextCharFormat] = [] + style = cast("pygments.style.StyleMeta", self.style) + self._style: Mapping[_TokenType, QTextCharFormat] + self._style = {token: get_text_char_format(style) for token, style in style} - def format(self, tokensource, outfile): + def format( + self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any + ) -> None: """Format the given token stream. - `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`. + When Qt calls the highlightBlock method on a `CodeSyntaxHighlight` object, + `highlight(text, self.lexer, self.formatter)`, which trigger pygments to call + this method. + + Normally, this method puts output into `outfile`, but in Qt we do not produce + string output; instead we collect QTextCharFormat objects in `self.data`, which + can be used to apply formatting in the `highlightBlock` method that triggered + this method. """ self.data = [] - + null = QTextCharFormat() for token, value in tokensource: # using get method to workaround not defined style for plain token # https://github.com/pygments/pygments/issues/2149 - self.data.extend( - [self._style.get(token, QtGui.QTextCharFormat())] * len(value) - ) + self.data.extend([self._style.get(token, null)] * len(value)) + + +class CodeSyntaxHighlight(QSyntaxHighlighter): + """A syntax highlighter for code using Pygments. + + Parameters + ---------- + parent : QTextDocument | QObject | None + The parent object. Usually a QTextDocument. To use this class with a + QTextArea, pass in `text_area.document()`. + lang : str + The language of the code to highlight. This should be a string that + Pygments recognizes, e.g. 'python', 'pytb', 'cpp', 'java', etc. + theme : KnownStyle | str + The name of the Pygments style to use. For a complete list of available + styles, use `pygments.styles.get_all_styles()`. + + Examples + -------- + ```python + from qtpy.QtWidgets import QTextEdit + from superqt.utils import CodeSyntaxHighlight + text_area = QTextEdit() + highlighter = CodeSyntaxHighlight(text_area.document(), "python", "monokai") + + # then manually apply the background color to the text area. + palette = text_area.palette() + bgrd_color = QColor(self._highlight.background_color) + palette.setColor(QPalette.ColorRole.Base, bgrd_color) + text_area.setPalette(palette) + ``` + """ + + def __init__( + self, + parent: SupportsDocumentAndPalette | QTextDocument | QObject | None, + lang: str, + theme: KnownStyle | str = "default", + ) -> None: + self._doc_parent: SupportsDocumentAndPalette | None = None + if ( + parent + and not isinstance(parent, QTextDocument) + and hasattr(parent, "document") + and callable(parent.document) + and isinstance(doc := parent.document(), QTextDocument) + ): + if hasattr(parent, "palette") and hasattr(parent, "setPalette"): + self._doc_parent = cast("SupportsDocumentAndPalette", parent) + parent = doc -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)() + except ClassNotFound as e: + if cls := find_lexer_class(lang): + self.lexer = cls() + else: + raise ValueError(f"Could not find lexer for language {lang!r}.") from e + self.formatter: QFormatter + self.set_theme(theme) + + def set_theme(self, theme: KnownStyle | str) -> None: + self.formatter = QFormatter(style=theme) + if self._doc_parent is not None: + palette = self._doc_parent.palette() + bgrd = QColor(self.background_color) + palette.setColor(QPalette.ColorRole.Base, bgrd) + self._doc_parent.setPalette(palette) + + self.rehighlight() @property - def background_color(self): - return self.formatter.style.background_color + def background_color(self) -> str: + style = cast("pygments.style.StyleMeta", self.formatter.style) + return style.background_color - def highlightBlock(self, text): + def highlightBlock(self, text: str | None) -> None: # dirty, dirty hack - # The core problem is that pygemnts by default use string streams, + # The core problem is that pygments by default use string streams, # that will not handle QTextCharFormat, so we need use `data` property to # work around this. - highlight(text, self.lexer, self.formatter) - for i in range(len(text)): - self.setFormat(i, 1, self.formatter.data[i]) + if text: + highlight(text, self.lexer, self.formatter) + for i in range(len(text)): + self.setFormat(i, 1, self.formatter.data[i]) From a6a41c6333e454fe5924d53fc50842bf3678f236 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 12:05:33 -0500 Subject: [PATCH 2/6] add docstring --- src/superqt/utils/_code_syntax_highlight.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/superqt/utils/_code_syntax_highlight.py b/src/superqt/utils/_code_syntax_highlight.py index a1ce66c6..7ebbe4a3 100644 --- a/src/superqt/utils/_code_syntax_highlight.py +++ b/src/superqt/utils/_code_syntax_highlight.py @@ -231,6 +231,7 @@ def __init__( self.set_theme(theme) def set_theme(self, theme: KnownStyle | str) -> None: + """Set the theme for the syntax highlighting.""" self.formatter = QFormatter(style=theme) if self._doc_parent is not None: palette = self._doc_parent.palette() From aae51e888268740236620812628e7db7ade887c3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 12:14:22 -0500 Subject: [PATCH 3/6] update methods --- src/superqt/utils/_code_syntax_highlight.py | 33 ++++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/superqt/utils/_code_syntax_highlight.py b/src/superqt/utils/_code_syntax_highlight.py index 7ebbe4a3..0b2bc493 100644 --- a/src/superqt/utils/_code_syntax_highlight.py +++ b/src/superqt/utils/_code_syntax_highlight.py @@ -220,18 +220,15 @@ def __init__( parent = doc super().__init__(parent) - try: - self.lexer = get_lexer_by_name(lang) - except ClassNotFound as e: - if cls := find_lexer_class(lang): - self.lexer = cls() - else: - raise ValueError(f"Could not find lexer for language {lang!r}.") from e - self.formatter: QFormatter - self.set_theme(theme) + self.setLanguage(lang) + self.setTheme(theme) + + def setTheme(self, theme: KnownStyle | str) -> None: + """Set the theme for the syntax highlighting. - def set_theme(self, theme: KnownStyle | str) -> None: - """Set the theme for the syntax highlighting.""" + This should be a string that Pygments recognizes, e.g. 'monokai', 'solarized'. + Use `pygments.styles.get_all_styles()` to see a list of available styles. + """ self.formatter = QFormatter(style=theme) if self._doc_parent is not None: palette = self._doc_parent.palette() @@ -241,6 +238,20 @@ def set_theme(self, theme: KnownStyle | str) -> None: self.rehighlight() + def setLanguage(self, lang: str) -> None: + """Set the language for the syntax highlighting. + + This should be a string that Pygments recognizes, e.g. 'python', 'pytb', 'cpp', + 'java', etc. + """ + try: + self.lexer = get_lexer_by_name(lang) + except ClassNotFound as e: + if cls := find_lexer_class(lang): + self.lexer = cls() + else: + raise ValueError(f"Could not find lexer for language {lang!r}.") from e + @property def background_color(self) -> str: style = cast("pygments.style.StyleMeta", self.formatter.style) From 389394719317ee7fce15ff6cd4d78f0c52580188 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 12:16:04 -0500 Subject: [PATCH 4/6] remove unneeded import --- src/superqt/utils/_code_syntax_highlight.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/superqt/utils/_code_syntax_highlight.py b/src/superqt/utils/_code_syntax_highlight.py index 0b2bc493..5b87c8cf 100644 --- a/src/superqt/utils/_code_syntax_highlight.py +++ b/src/superqt/utils/_code_syntax_highlight.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING, Any, cast -import pygments.token from pygments import highlight from pygments.formatter import Formatter from pygments.lexers import find_lexer_class, get_lexer_by_name From 1638c0c2211810abbafc31041aad8d3e94790f39 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 13:56:42 -0500 Subject: [PATCH 5/6] apply code review --- src/superqt/utils/_code_syntax_highlight.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/superqt/utils/_code_syntax_highlight.py b/src/superqt/utils/_code_syntax_highlight.py index 5b87c8cf..9935a5fb 100644 --- a/src/superqt/utils/_code_syntax_highlight.py +++ b/src/superqt/utils/_code_syntax_highlight.py @@ -97,6 +97,7 @@ class FormatterKwargs(TypedDict, total=False): "Consolas", "Andale Mono", "Source Code Pro", + "Ubuntu Mono", "monospace", ] @@ -126,7 +127,7 @@ def get_text_char_format(style: _StyleDict) -> QTextCharFormat: if color := style.get("color"): text_char_format.setForeground(QColor(f"#{color}")) if bgcolor := style.get("bgcolor"): - text_char_format.setBackground(QColor(bgcolor)) + text_char_format.setBackground(QColor(f"#{bgcolor}")) if style.get("bold"): text_char_format.setFontWeight(QFont.Weight.Bold) if style.get("italic"): From 35819c00a27095c6fa640f0f9da0082bc433e39b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 14:32:19 -0500 Subject: [PATCH 6/6] add ignore --- .github/workflows/test_and_deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 58b7dc63..108629ff 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -94,7 +94,7 @@ jobs: dependency-ref: ${{ matrix.napari-version }} dependency-extras: "testing" qt: ${{ matrix.qt }} - pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor"' + pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor and not preferences_dialog_not_dismissed"' python-version: "3.10" post-install-cmd: "pip install lxml_html_clean" strategy: