Files
BondGraph/BEdit/src/bedit/gui/editors/openmodelica.py

253 lines
9.1 KiB
Python

import json
from importlib.resources import files
from PySide6.QtCore import QRegularExpression, QStringListModel, Qt
from PySide6.QtGui import (
QColor,
QFont,
QKeyEvent,
QSyntaxHighlighter,
QTextCharFormat,
QTextCursor,
)
from PySide6.QtWidgets import QCompleter, QPlainTextEdit
from bedit.gui.preferences import application_settings
HIGHLIGHT_STYLES = {
"keywords": ("Keywords", "#7c3aed", True, False),
"types": ("Types", "#0369a1", True, False),
"builtins": ("Built-ins", "#0f766e", False, False),
"bevalues": ("BEvalues", "#c026d3", True, False),
"inputs": ("Inputs", "#1d4ed8", False, False),
"outputs": ("Outputs", "#be123c", False, False),
"parameters": ("Parameters", "#a16207", False, False),
"numbers": ("Numbers", "#b45309", False, False),
"strings": ("Strings", "#15803d", False, False),
"comments": ("Comments", "#6b7280", False, True),
}
def load_openmodelica_syntax() -> dict[str, list[str]]:
resource = files("bedit").joinpath("data/syntax/openmodelica.json")
with resource.open(encoding="utf-8") as file:
values = json.load(file)
return {
category: [str(word) for word in values.get(category, [])]
for category in ("keywords", "types", "builtins", "bevalues")
}
def _format(color: str, *, bold: bool = False, italic: bool = False) -> QTextCharFormat:
value = QTextCharFormat()
value.setForeground(QColor(color))
value.setFontWeight(QFont.Weight.Bold if bold else QFont.Weight.Normal)
value.setFontItalic(italic)
return value
def _as_bool(value) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def highlighting_style(category: str) -> tuple[str, bool, bool]:
_label, default_color, default_bold, default_italic = HIGHLIGHT_STYLES[category]
settings = application_settings()
prefix = f"syntax/{category}"
return (
str(settings.value(f"{prefix}/color", default_color)),
_as_bool(settings.value(f"{prefix}/bold", default_bold)),
_as_bool(settings.value(f"{prefix}/italic", default_italic)),
)
class OpenModelicaHighlighter(QSyntaxHighlighter):
"""Syntax highlighter driven by the editable OpenModelica word list."""
def __init__(
self,
document,
syntax: dict[str, list[str]],
symbols: dict[str, list[str]],
) -> None:
super().__init__(document)
self.rules: list[tuple[QRegularExpression, QTextCharFormat]] = []
for category in (
"keywords",
"types",
"builtins",
"inputs",
"outputs",
"parameters",
):
words = syntax.get(category, symbols.get(category, []))
if words:
pattern = r"\b(?:" + "|".join(map(QRegularExpression.escape, words)) + r")\b"
color, bold, italic = highlighting_style(category)
self.rules.append(
(QRegularExpression(pattern), _format(color, bold=bold, italic=italic))
)
bevalue_style = highlighting_style("bevalues")
number_style = highlighting_style("numbers")
string_style = highlighting_style("strings")
self.rules.extend(
[
(
QRegularExpression(r"\$[A-Za-z_][A-Za-z0-9_]*\$"),
_format(
bevalue_style[0],
bold=bevalue_style[1],
italic=bevalue_style[2],
),
),
(
QRegularExpression(r"\b(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b"),
_format(number_style[0], bold=number_style[1], italic=number_style[2]),
),
(
QRegularExpression(r'"(?:\\.|[^"\\])*"'),
_format(string_style[0], bold=string_style[1], italic=string_style[2]),
),
]
)
comment_style = highlighting_style("comments")
self.comment_format = _format(
comment_style[0], bold=comment_style[1], italic=comment_style[2]
)
self.rules.append((QRegularExpression(r"//.*$"), self.comment_format))
self.comment_start = QRegularExpression(r"/\*")
self.comment_end = QRegularExpression(r"\*/")
def highlightBlock(self, text: str) -> None: # noqa: N802
for expression, text_format in self.rules:
match = expression.globalMatch(text)
while match.hasNext():
result = match.next()
self.setFormat(result.capturedStart(), result.capturedLength(), text_format)
self.setCurrentBlockState(0)
start = (
0
if self.previousBlockState() == 1
else self.comment_start.match(text).capturedStart()
)
while start >= 0:
end_match = self.comment_end.match(text, start + 2)
if end_match.hasMatch():
length = end_match.capturedEnd() - start
else:
self.setCurrentBlockState(1)
length = len(text) - start
self.setFormat(start, length, self.comment_format)
if not end_match.hasMatch():
break
start = self.comment_start.match(text, start + length).capturedStart()
class OpenModelicaEditor(QPlainTextEdit):
"""OpenModelica text editor with syntax highlighting and completion."""
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.setPlaceholderText("Enter OpenModelica equations here…")
self.syntax = load_openmodelica_syntax()
self.symbols = {"inputs": [], "outputs": [], "parameters": []}
self.highlighter = OpenModelicaHighlighter(
self.document(), self.syntax, self.symbols
)
self.completer = QCompleter(self)
self.completer.setWidget(self)
self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.completer.activated.connect(self._insert_completion)
self._rebuild_completions()
def set_symbols(
self,
inputs: list[str],
outputs: list[str],
parameters: list[str],
) -> None:
self.symbols = {
"inputs": [name for name in inputs if name],
"outputs": [name for name in outputs if name],
"parameters": [name for name in parameters if name],
}
self.reload_highlighting()
def reload_highlighting(self) -> None:
self.highlighter.setDocument(None)
self.highlighter = OpenModelicaHighlighter(
self.document(), self.syntax, self.symbols
)
self._rebuild_completions()
def _rebuild_completions(self) -> None:
words = sorted(
{
*self.syntax["keywords"],
*self.syntax["types"],
*self.syntax["builtins"],
*(f"${name.strip('$')}$" for name in self.syntax["bevalues"]),
*self.symbols["inputs"],
*self.symbols["outputs"],
*self.symbols["parameters"],
},
key=str.casefold,
)
self.completer.setModel(QStringListModel(words, self.completer))
def _completion_prefix(self) -> str:
cursor = self.textCursor()
text = cursor.block().text()[: cursor.positionInBlock()]
index = len(text)
while index > 0 and (text[index - 1].isalnum() or text[index - 1] in "_$"):
index -= 1
return text[index:]
def _insert_completion(self, completion: str) -> None:
prefix = self._completion_prefix()
cursor = self.textCursor()
cursor.movePosition(
QTextCursor.MoveOperation.Left,
QTextCursor.MoveMode.KeepAnchor,
len(prefix),
)
cursor.insertText(completion)
self.setTextCursor(cursor)
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
popup = self.completer.popup()
if popup.isVisible() and event.key() in {
Qt.Key.Key_Enter,
Qt.Key.Key_Return,
Qt.Key.Key_Escape,
Qt.Key.Key_Tab,
Qt.Key.Key_Backtab,
}:
event.ignore()
return
explicit = (
event.modifiers() == Qt.KeyboardModifier.ControlModifier
and event.key() == Qt.Key.Key_Space
)
if not explicit:
super().keyPressEvent(event)
prefix = self._completion_prefix()
if not explicit and (len(prefix) < 2 or event.text() == ""):
popup.hide()
return
self.completer.setCompletionPrefix(prefix)
if self.completer.completionCount() == 0:
popup.hide()
return
rectangle = self.cursorRect()
rectangle.setWidth(
popup.sizeHintForColumn(0) + popup.verticalScrollBar().sizeHint().width()
)
self.completer.complete(rectangle)