Skip to content

Commit 6a7a731

Browse files
authored
feat: Improve CodeSyntaxHighlight object (#268)
1 parent 4da5ac2 commit 6a7a731

File tree

2 files changed

+230
-43
lines changed

2 files changed

+230
-43
lines changed

.github/workflows/test_and_deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ jobs:
9494
dependency-ref: ${{ matrix.napari-version }}
9595
dependency-extras: "testing"
9696
qt: ${{ matrix.qt }}
97-
pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor"'
97+
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"'
9898
python-version: "3.10"
9999
post-install-cmd: "pip install lxml_html_clean"
100100
strategy:
Lines changed: 229 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,268 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any, cast
4+
15
from pygments import highlight
26
from pygments.formatter import Formatter
37
from pygments.lexers import find_lexer_class, get_lexer_by_name
48
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+
)
617

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+
]
1083

84+
class FormatterKwargs(TypedDict, total=False):
85+
style: KnownStyle | str
86+
full: bool
87+
title: str
88+
encoding: str
89+
outencoding: str
1190

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']}"))
2291

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+
]
25103

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}"))
26131
if style.get("bold"):
27-
text_char_format.setFontWeight(QtGui.QFont.Bold)
132+
text_char_format.setFontWeight(QFont.Weight.Bold)
28133
if style.get("italic"):
29134
text_char_format.setFontItalic(True)
30135
if style.get("underline"):
31136
text_char_format.setFontUnderline(True)
32-
33-
# TODO find if it is possible to support border style.
34-
137+
# if style.get("border"):
138+
# ...
35139
return text_char_format
36140

37141

38142
class QFormatter(Formatter):
39-
def __init__(self, **kwargs):
143+
def __init__(self, **kwargs: Unpack[FormatterKwargs]) -> None:
40144
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}
43149

44-
def format(self, tokensource, outfile):
150+
def format(
151+
self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any
152+
) -> None:
45153
"""Format the given token stream.
46154
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.
50163
"""
51164
self.data = []
52-
165+
null = QTextCharFormat()
53166
for token, value in tokensource:
54167
# using get method to workaround not defined style for plain token
55168
# 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+
"""
59203

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
60221

61-
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
62-
def __init__(self, parent, lang, theme):
63222
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+
"""
64232
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+
"""
65247
try:
66248
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
69254

70255
@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
73259

74-
def highlightBlock(self, text):
260+
def highlightBlock(self, text: str | None) -> None:
75261
# 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,
77263
# that will not handle QTextCharFormat, so we need use `data` property to
78264
# 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

Comments
 (0)