Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Improve CodeSyntaxHighlight object #268

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
271 changes: 229 additions & 42 deletions src/superqt/utils/_code_syntax_highlight.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,268 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, cast

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[
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any plan for trace pigments style updates (ex compare pygments.styles.STYLES content witrh this annotation in tests) or just wait for issues?

Copy link
Member Author

@tlambert03 tlambert03 Dec 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no plan... but I'll mention that the type hint here is actually:

theme: KnownStyle | str = "default",

which means that users will get the autocomplete suggestions, but it's not an error to enter something that is not in the KnownStyle list. So the only "issue" we might see is that someone didn't get a nice autocomplete hint :) (but mypy won't mind)

"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",
]

class FormatterKwargs(TypedDict, total=False):
style: KnownStyle | str
full: bool
title: str
encoding: str
outencoding: str

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

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",
"Ubuntu Mono",
"monospace",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that it may be worth to and Ubuntu Mono - the default mono font on Ubuntu system.

]


# 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)

Check warning on line 126 in src/superqt/utils/_code_syntax_highlight.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_code_syntax_highlight.py#L126

Added line #L126 was not covered by tests
if color := style.get("color"):
text_char_format.setForeground(QColor(f"#{color}"))
if bgcolor := style.get("bgcolor"):
text_char_format.setBackground(QColor(f"#{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.setLanguage(lang)
self.setTheme(theme)

def setTheme(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()
bgrd = QColor(self.background_color)
palette.setColor(QPalette.ColorRole.Base, bgrd)
self._doc_parent.setPalette(palette)

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:
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

Check warning on line 253 in src/superqt/utils/_code_syntax_highlight.py

View check run for this annotation

Codecov / codecov/patch

src/superqt/utils/_code_syntax_highlight.py#L253

Added line #L253 was not covered by tests

@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])
Loading