|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +from typing import TYPE_CHECKING, Any, cast |
| 4 | + |
1 | 5 | from pygments import highlight
|
2 | 6 | from pygments.formatter import Formatter
|
3 | 7 | from pygments.lexers import find_lexer_class, get_lexer_by_name
|
4 | 8 | from pygments.util import ClassNotFound
|
5 |
| -from qtpy import QtGui |
| 9 | +from qtpy.QtGui import ( |
| 10 | + QColor, |
| 11 | + QFont, |
| 12 | + QPalette, |
| 13 | + QSyntaxHighlighter, |
| 14 | + QTextCharFormat, |
| 15 | + QTextDocument, |
| 16 | +) |
6 | 17 |
|
7 |
| -# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py |
8 |
| -# (MIT license) and |
9 |
| -# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter |
| 18 | +if TYPE_CHECKING: |
| 19 | + from collections.abc import Mapping, Sequence |
| 20 | + from typing import Literal, TypeAlias, TypedDict, Unpack |
| 21 | + |
| 22 | + import pygments.style |
| 23 | + from pygments.style import _StyleDict |
| 24 | + from pygments.token import _TokenType |
| 25 | + from qtpy.QtCore import QObject |
| 26 | + |
| 27 | + class SupportsDocumentAndPalette(QObject): |
| 28 | + def document(self) -> QTextDocument | None: ... |
| 29 | + def palette(self) -> QPalette: ... |
| 30 | + def setPalette(self, palette: QPalette) -> None: ... |
| 31 | + |
| 32 | + KnownStyle: TypeAlias = Literal[ |
| 33 | + "abap", |
| 34 | + "algol", |
| 35 | + "algol_nu", |
| 36 | + "arduino", |
| 37 | + "autumn", |
| 38 | + "bw", |
| 39 | + "borland", |
| 40 | + "coffee", |
| 41 | + "colorful", |
| 42 | + "default", |
| 43 | + "dracula", |
| 44 | + "emacs", |
| 45 | + "friendly_grayscale", |
| 46 | + "friendly", |
| 47 | + "fruity", |
| 48 | + "github-dark", |
| 49 | + "gruvbox-dark", |
| 50 | + "gruvbox-light", |
| 51 | + "igor", |
| 52 | + "inkpot", |
| 53 | + "lightbulb", |
| 54 | + "lilypond", |
| 55 | + "lovelace", |
| 56 | + "manni", |
| 57 | + "material", |
| 58 | + "monokai", |
| 59 | + "murphy", |
| 60 | + "native", |
| 61 | + "nord-darker", |
| 62 | + "nord", |
| 63 | + "one-dark", |
| 64 | + "paraiso-dark", |
| 65 | + "paraiso-light", |
| 66 | + "pastie", |
| 67 | + "perldoc", |
| 68 | + "rainbow_dash", |
| 69 | + "rrt", |
| 70 | + "sas", |
| 71 | + "solarized-dark", |
| 72 | + "solarized-light", |
| 73 | + "staroffice", |
| 74 | + "stata-dark", |
| 75 | + "stata-light", |
| 76 | + "tango", |
| 77 | + "trac", |
| 78 | + "vim", |
| 79 | + "vs", |
| 80 | + "xcode", |
| 81 | + "zenburn", |
| 82 | + ] |
10 | 83 |
|
| 84 | + class FormatterKwargs(TypedDict, total=False): |
| 85 | + style: KnownStyle | str |
| 86 | + full: bool |
| 87 | + title: str |
| 88 | + encoding: str |
| 89 | + outencoding: str |
11 | 90 |
|
12 |
| -def get_text_char_format( |
13 |
| - style: dict[str, QtGui.QTextCharFormat], |
14 |
| -) -> QtGui.QTextCharFormat: |
15 |
| - text_char_format = QtGui.QTextCharFormat() |
16 |
| - if hasattr(text_char_format, "setFontFamilies"): |
17 |
| - text_char_format.setFontFamilies(["monospace"]) |
18 |
| - else: |
19 |
| - text_char_format.setFontFamily("monospace") |
20 |
| - if style.get("color"): |
21 |
| - text_char_format.setForeground(QtGui.QColor(f"#{style['color']}")) |
22 | 91 |
|
23 |
| - if style.get("bgcolor"): |
24 |
| - text_char_format.setBackground(QtGui.QColor(style["bgcolor"])) |
| 92 | +MONO_FAMILIES = [ |
| 93 | + "Menlo", |
| 94 | + "Courier New", |
| 95 | + "Courier", |
| 96 | + "Monaco", |
| 97 | + "Consolas", |
| 98 | + "Andale Mono", |
| 99 | + "Source Code Pro", |
| 100 | + "Ubuntu Mono", |
| 101 | + "monospace", |
| 102 | +] |
25 | 103 |
|
| 104 | + |
| 105 | +# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py |
| 106 | +# (MIT license) and |
| 107 | +# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter |
| 108 | +def get_text_char_format(style: _StyleDict) -> QTextCharFormat: |
| 109 | + """Return a QTextCharFormat object based on the given Pygments `_StyleDict`. |
| 110 | +
|
| 111 | + style will likely have these keys: |
| 112 | + - color: str | None |
| 113 | + - bold: bool |
| 114 | + - italic: bool |
| 115 | + - underline: bool |
| 116 | + - bgcolor: str | None |
| 117 | + - border: str | None |
| 118 | + - roman: bool | None |
| 119 | + - sans: bool | None |
| 120 | + - mono: bool | None |
| 121 | + - ansicolor: str | None |
| 122 | + - bgansicolor: str | None |
| 123 | + """ |
| 124 | + text_char_format = QTextCharFormat() |
| 125 | + if style.get("mono"): |
| 126 | + text_char_format.setFontFamilies(MONO_FAMILIES) |
| 127 | + if color := style.get("color"): |
| 128 | + text_char_format.setForeground(QColor(f"#{color}")) |
| 129 | + if bgcolor := style.get("bgcolor"): |
| 130 | + text_char_format.setBackground(QColor(f"#{bgcolor}")) |
26 | 131 | if style.get("bold"):
|
27 |
| - text_char_format.setFontWeight(QtGui.QFont.Bold) |
| 132 | + text_char_format.setFontWeight(QFont.Weight.Bold) |
28 | 133 | if style.get("italic"):
|
29 | 134 | text_char_format.setFontItalic(True)
|
30 | 135 | if style.get("underline"):
|
31 | 136 | text_char_format.setFontUnderline(True)
|
32 |
| - |
33 |
| - # TODO find if it is possible to support border style. |
34 |
| - |
| 137 | + # if style.get("border"): |
| 138 | + # ... |
35 | 139 | return text_char_format
|
36 | 140 |
|
37 | 141 |
|
38 | 142 | class QFormatter(Formatter):
|
39 |
| - def __init__(self, **kwargs): |
| 143 | + def __init__(self, **kwargs: Unpack[FormatterKwargs]) -> None: |
40 | 144 | super().__init__(**kwargs)
|
41 |
| - self.data: list[QtGui.QTextCharFormat] = [] |
42 |
| - self._style = {name: get_text_char_format(style) for name, style in self.style} |
| 145 | + self.data: list[QTextCharFormat] = [] |
| 146 | + style = cast("pygments.style.StyleMeta", self.style) |
| 147 | + self._style: Mapping[_TokenType, QTextCharFormat] |
| 148 | + self._style = {token: get_text_char_format(style) for token, style in style} |
43 | 149 |
|
44 |
| - def format(self, tokensource, outfile): |
| 150 | + def format( |
| 151 | + self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any |
| 152 | + ) -> None: |
45 | 153 | """Format the given token stream.
|
46 | 154 |
|
47 |
| - `outfile` is argument from parent class, but |
48 |
| - in Qt we do not produce string output, but QTextCharFormat, so it needs to be |
49 |
| - collected using `self.data`. |
| 155 | + When Qt calls the highlightBlock method on a `CodeSyntaxHighlight` object, |
| 156 | + `highlight(text, self.lexer, self.formatter)`, which trigger pygments to call |
| 157 | + this method. |
| 158 | +
|
| 159 | + Normally, this method puts output into `outfile`, but in Qt we do not produce |
| 160 | + string output; instead we collect QTextCharFormat objects in `self.data`, which |
| 161 | + can be used to apply formatting in the `highlightBlock` method that triggered |
| 162 | + this method. |
50 | 163 | """
|
51 | 164 | self.data = []
|
52 |
| - |
| 165 | + null = QTextCharFormat() |
53 | 166 | for token, value in tokensource:
|
54 | 167 | # using get method to workaround not defined style for plain token
|
55 | 168 | # https://github.com/pygments/pygments/issues/2149
|
56 |
| - self.data.extend( |
57 |
| - [self._style.get(token, QtGui.QTextCharFormat())] * len(value) |
58 |
| - ) |
| 169 | + self.data.extend([self._style.get(token, null)] * len(value)) |
| 170 | + |
| 171 | + |
| 172 | +class CodeSyntaxHighlight(QSyntaxHighlighter): |
| 173 | + """A syntax highlighter for code using Pygments. |
| 174 | +
|
| 175 | + Parameters |
| 176 | + ---------- |
| 177 | + parent : QTextDocument | QObject | None |
| 178 | + The parent object. Usually a QTextDocument. To use this class with a |
| 179 | + QTextArea, pass in `text_area.document()`. |
| 180 | + lang : str |
| 181 | + The language of the code to highlight. This should be a string that |
| 182 | + Pygments recognizes, e.g. 'python', 'pytb', 'cpp', 'java', etc. |
| 183 | + theme : KnownStyle | str |
| 184 | + The name of the Pygments style to use. For a complete list of available |
| 185 | + styles, use `pygments.styles.get_all_styles()`. |
| 186 | +
|
| 187 | + Examples |
| 188 | + -------- |
| 189 | + ```python |
| 190 | + from qtpy.QtWidgets import QTextEdit |
| 191 | + from superqt.utils import CodeSyntaxHighlight |
| 192 | +
|
| 193 | + text_area = QTextEdit() |
| 194 | + highlighter = CodeSyntaxHighlight(text_area.document(), "python", "monokai") |
| 195 | +
|
| 196 | + # then manually apply the background color to the text area. |
| 197 | + palette = text_area.palette() |
| 198 | + bgrd_color = QColor(self._highlight.background_color) |
| 199 | + palette.setColor(QPalette.ColorRole.Base, bgrd_color) |
| 200 | + text_area.setPalette(palette) |
| 201 | + ``` |
| 202 | + """ |
59 | 203 |
|
| 204 | + def __init__( |
| 205 | + self, |
| 206 | + parent: SupportsDocumentAndPalette | QTextDocument | QObject | None, |
| 207 | + lang: str, |
| 208 | + theme: KnownStyle | str = "default", |
| 209 | + ) -> None: |
| 210 | + self._doc_parent: SupportsDocumentAndPalette | None = None |
| 211 | + if ( |
| 212 | + parent |
| 213 | + and not isinstance(parent, QTextDocument) |
| 214 | + and hasattr(parent, "document") |
| 215 | + and callable(parent.document) |
| 216 | + and isinstance(doc := parent.document(), QTextDocument) |
| 217 | + ): |
| 218 | + if hasattr(parent, "palette") and hasattr(parent, "setPalette"): |
| 219 | + self._doc_parent = cast("SupportsDocumentAndPalette", parent) |
| 220 | + parent = doc |
60 | 221 |
|
61 |
| -class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter): |
62 |
| - def __init__(self, parent, lang, theme): |
63 | 222 | super().__init__(parent)
|
| 223 | + self.setLanguage(lang) |
| 224 | + self.setTheme(theme) |
| 225 | + |
| 226 | + def setTheme(self, theme: KnownStyle | str) -> None: |
| 227 | + """Set the theme for the syntax highlighting. |
| 228 | +
|
| 229 | + This should be a string that Pygments recognizes, e.g. 'monokai', 'solarized'. |
| 230 | + Use `pygments.styles.get_all_styles()` to see a list of available styles. |
| 231 | + """ |
64 | 232 | self.formatter = QFormatter(style=theme)
|
| 233 | + if self._doc_parent is not None: |
| 234 | + palette = self._doc_parent.palette() |
| 235 | + bgrd = QColor(self.background_color) |
| 236 | + palette.setColor(QPalette.ColorRole.Base, bgrd) |
| 237 | + self._doc_parent.setPalette(palette) |
| 238 | + |
| 239 | + self.rehighlight() |
| 240 | + |
| 241 | + def setLanguage(self, lang: str) -> None: |
| 242 | + """Set the language for the syntax highlighting. |
| 243 | +
|
| 244 | + This should be a string that Pygments recognizes, e.g. 'python', 'pytb', 'cpp', |
| 245 | + 'java', etc. |
| 246 | + """ |
65 | 247 | try:
|
66 | 248 | self.lexer = get_lexer_by_name(lang)
|
67 |
| - except ClassNotFound: |
68 |
| - self.lexer = find_lexer_class(lang)() |
| 249 | + except ClassNotFound as e: |
| 250 | + if cls := find_lexer_class(lang): |
| 251 | + self.lexer = cls() |
| 252 | + else: |
| 253 | + raise ValueError(f"Could not find lexer for language {lang!r}.") from e |
69 | 254 |
|
70 | 255 | @property
|
71 |
| - def background_color(self): |
72 |
| - return self.formatter.style.background_color |
| 256 | + def background_color(self) -> str: |
| 257 | + style = cast("pygments.style.StyleMeta", self.formatter.style) |
| 258 | + return style.background_color |
73 | 259 |
|
74 |
| - def highlightBlock(self, text): |
| 260 | + def highlightBlock(self, text: str | None) -> None: |
75 | 261 | # dirty, dirty hack
|
76 |
| - # The core problem is that pygemnts by default use string streams, |
| 262 | + # The core problem is that pygments by default use string streams, |
77 | 263 | # that will not handle QTextCharFormat, so we need use `data` property to
|
78 | 264 | # work around this.
|
79 |
| - highlight(text, self.lexer, self.formatter) |
80 |
| - for i in range(len(text)): |
81 |
| - self.setFormat(i, 1, self.formatter.data[i]) |
| 265 | + if text: |
| 266 | + highlight(text, self.lexer, self.formatter) |
| 267 | + for i in range(len(text)): |
| 268 | + self.setFormat(i, 1, self.formatter.data[i]) |
0 commit comments