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)