nmreval/src/gui_qt/editors/codeeditor.py
2024-10-10 19:22:57 +02:00

312 lines
11 KiB
Python

# CodeEditor based on QT example, Python syntax highlighter found on Python site
import enum
import typing
from ast import parse
from PyQt5.QtGui import QTextCharFormat
from ..Qt import QtGui, QtCore, QtWidgets
def _make_textformats(color, style=''):
"""Return a QTextCharFormat with the given attributes.
"""
_color = QtGui.QColor()
_color.setNamedColor(color)
_format = QtGui.QTextCharFormat()
_format.setForeground(_color)
if 'bold' in style:
_format.setFontWeight(QtGui.QFont.Weight.Bold)
if 'italic' in style:
_format.setFontItalic(True)
return _format
# Syntax styles that can be shared by all languages
class CodeStyle(enum.Enum):
KEYWORD = _make_textformats('blue')
OPERATOR = _make_textformats('black')
BRACE = _make_textformats('black')
CLASS_DEFINE = _make_textformats('black', 'bold')
STRING = _make_textformats('darkGreen')
COMMENT = _make_textformats('gray', 'italic')
SELF = _make_textformats('brown', 'italic')
PROPERTY = _make_textformats('brown')
NUMBER = _make_textformats('darkRed')
class PythonHighlighter(QtGui.QSyntaxHighlighter):
"""
Syntax highlighter for the Python language.
"""
# Python keywords
keywords = [
'and', 'assert', 'break', 'class', 'continue', 'def',
'del', 'elif', 'else', 'except', 'exec', 'finally',
'for', 'from', 'global', 'if', 'import', 'in',
'is', 'lambda', 'not', 'or', 'pass', 'print',
'raise', 'return', 'try', 'while', 'yield',
'None', 'True', 'False', 'object'
]
def __init__(self, document):
super().__init__(document)
# Multi-line strings (expression, flag, style)
# FIXME: The triple-quotes in these two lines will mess up the
# syntax highlighting from this point onward
self.tri_single = (QtCore.QRegularExpression("r'{3}"), 1, CodeStyle.STRING.value)
self.tri_double = (QtCore.QRegularExpression('r?"{3}'), 2, CodeStyle.STRING.value)
rules = []
# Keyword, operator, and brace rules
rules += [(rf'\b{w}\b', 0, CodeStyle.KEYWORD.value) for w in PythonHighlighter.keywords]
# Other rules
rules += [
# 'self'
(r'\bself\b', 0, CodeStyle.SELF.value),
# 'def' followed by an identifier
(r'\bdef\b\s*(\w+)', 1, CodeStyle.CLASS_DEFINE.value),
# 'class' followed by an identifier
(r'\bclass\b\s*(\w+)', 1, CodeStyle.CLASS_DEFINE.value),
# decorator @ followed by a word
(r'\s*@(\w+)\s*', 0, CodeStyle.PROPERTY.value),
# Numeric literals
(r'\b[+-]?\d+[lL]?\b', 0, CodeStyle.NUMBER.value),
(r'\b[+-]?0[xX][\dA-Fa-f]+[lL]?\b', 0, CodeStyle.NUMBER.value),
(r'\b[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b', 0, CodeStyle.NUMBER.value),
# Double-quoted string, possibly containing escape sequences
(r'[rf]?"[^"\\]*(\\.[^"\\]*)*"', 0, CodeStyle.STRING.value),
# Single-quoted string, possibly containing escape sequences
(r"[rf]?'[^'\\]*(\\.[^'\\]*)*'", 0, CodeStyle.STRING.value),
# From '#' until a newline
(r'#[^\n]*', 0, CodeStyle.COMMENT.value),
]
# Build a QRegExp for each pattern
self.rules = [(QtCore.QRegularExpression(pat), index, fmt) for (pat, index, fmt) in rules]
def highlightBlock(self, text):
"""
Apply syntax highlighting to the given block of text.
"""
# Do other syntax formatting
for expression, nth, rule in self.rules:
index = expression.globalMatch(text)
while index.hasNext():
match = index.next()
self.setFormat(match.capturedStart(nth), match.capturedLength(nth), rule)
self.setCurrentBlockState(0)
# Do multi-line strings
in_multiline = self.match_multiline(text, *self.tri_single)
if not in_multiline:
in_multiline = self.match_multiline(text, *self.tri_double)
def match_multiline(self, text: str, delimiter: QtCore.QRegularExpression, in_state: int, style: QtGui.QTextCharFormat):
"""
Highlighting of multi-line strings. ``delimiter`` should be a
``QRegExp`` for triple-single-quotes or triple-double-quotes, and
``in_state`` should be a unique integer to represent the corresponding
state changes when inside those strings. Returns True if we're still
inside a multi-line string when this function is finished.
"""
# If inside triple-single quotes, start at 0
# TODO
return False
# if self.previousBlockState() == in_state:
# start = 0
# add = 0
# # Otherwise, look for the delimiter on this line
# else:
# start = delimiter.match(text).
# # Move past this match
# add = delimiter.matchedLength()
#
# # As long as there's a delimiter match on this line...
# while start >= 0:
# # Look for the ending delimiter
# end = delimiter.indexIn(text, start + add)
# # Ending delimiter on this line?
# if end >= add:
# length = end - start + add + delimiter.matchedLength()
# self.setCurrentBlockState(0)
# # No; multi-line string
# else:
# self.setCurrentBlockState(in_state)
# try:
# length = text.length() - start + add
# except AttributeError:
# length = len(text) - start + add
# # Apply formatting
# self.setFormat(start, length, style)
# # Look for the next match
# start = delimiter.indexIn(text, start + length)
#
# # Return True if still inside a multi-line string, False otherwise
# if self.currentBlockState() == in_state:
# return True
# else:
# return False
class LineNumbers(QtWidgets.QWidget):
def __init__(self, editor):
super().__init__(editor)
self.editor = editor
def sizeHint(self):
return QtCore.QSize(self.editor.width_linenumber, 0)
def paintEvent(self, event):
self.editor.paintevent_linenumber(event)
class CodeEditor(QtWidgets.QPlainTextEdit):
# more or less a direct translation of the Qt example
def __init__(self, parent):
super().__init__(parent)
self.current_linenumber = LineNumbers(self)
self.blockCountChanged.connect(self.update_width_linenumber)
self.updateRequest.connect(self.update_current_area)
self.cursorPositionChanged.connect(self.highlight_current_line)
self.update_width_linenumber(0)
self.highlight = PythonHighlighter(self.document())
def keyPressEvent(self, evt):
if evt.key() == QtCore.Qt.Key.Key_Tab:
# use spaces instead of tab
self.insertPlainText(' '*4)
elif evt.key() == QtCore.Qt.Key.Key_Insert:
self.setOverwriteMode(not self.overwriteMode())
else:
super().keyPressEvent(evt)
def width_linenumber(self):
digits = 1
count = max(1, self.blockCount())
while count >= 10:
count /= 10
digits += 1
space = 6 + self.fontMetrics().horizontalAdvance('9') * digits
return space
def update_width_linenumber(self, _):
self.setViewportMargins(self.width_linenumber(), 0, 0, 0)
def update_current_area(self, rect, dy):
if dy:
self.current_linenumber.scroll(0, dy)
else:
self.current_linenumber.update(0, rect.y(), self.current_linenumber.width(), rect.height())
if rect.contains(self.viewport().rect()):
self.update_width_linenumber(0)
def resizeEvent(self, evt):
super().resizeEvent(evt)
cr = self.contentsRect()
self.current_linenumber.setGeometry(QtCore.QRect(cr.left(), cr.top(), self.width_linenumber(), cr.height()))
def paintevent_linenumber(self, evt):
painter = QtGui.QPainter(self.current_linenumber)
painter.fillRect(evt.rect(), QtCore.Qt.GlobalColor.lightGray)
block = self.firstVisibleBlock()
block_number = block.blockNumber()
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
bottom = top + self.blockBoundingRect(block).height()
# Just to make sure I use the right font
height = self.fontMetrics().height()
while block.isValid() and (top <= evt.rect().bottom()):
if block.isVisible() and (bottom >= evt.rect().top()):
number = str(block_number + 1)
painter.setPen(QtCore.Qt.GlobalColor.black)
painter.drawText(0, int(top), self.current_linenumber.width() - 3, height,
QtCore.Qt.AlignmentFlag.AlignRight, number)
block = block.next()
top = bottom
bottom = top + self.blockBoundingRect(block).height()
block_number += 1
def highlight_current_line(self):
extra_selections = []
if not self.isReadOnly():
selection = QtWidgets.QTextEdit.ExtraSelection()
line_color = QtGui.QColor(QtCore.Qt.GlobalColor.yellow).lighter(180)
selection.format.setBackground(line_color)
selection.format.setProperty(QtGui.QTextFormat.Property.FullWidthSelection, True)
selection.cursor = self.textCursor()
selection.cursor.clearSelection()
extra_selections.append(selection)
self.setExtraSelections(extra_selections)
class EditorWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent=parent)
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.editor = CodeEditor(self)
layout.addWidget(self.editor)
self.error_label = QtWidgets.QLabel(self)
font = QtGui.QFont()
font.setBold(True)
font.setWeight(75)
self.error_label.setFont(font)
self.error_label.setVisible(False)
layout.addWidget(self.error_label)
self.setLayout(layout)
for attr in ['appendPlainText', 'toPlainText', 'insertPlainText', 'setPlainText']:
setattr(self, attr, getattr(self.editor, attr))
self.editor.textChanged.connect(self._check_syntax)
def _check_syntax(self) -> (int, tuple[typing.Any]):
is_valid = True
# Compile into an AST and check for syntax errors.
try:
_ = parse(self.toPlainText(), filename='<string>')
except SyntaxError as e:
self.error_label.setText(f'Syntax error in line {e.lineno}: {e.args[0]}')
is_valid = False
except Exception as e:
self.error_label.setText(f'Unexpected error: {e.args[0]}')
is_valid = False
self.error_label.setVisible(not is_valid)