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

[ui] Python Script Editor Improvements #2587

Merged
merged 10 commits into from
Jan 10, 2025
2 changes: 2 additions & 0 deletions meshroom/ui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ def registerTypes():
from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper
from meshroom.ui.components.csvData import CsvData
from meshroom.ui.components.geom2D import Geom2D
from meshroom.ui.components.scriptEditor import PySyntaxHighlighter

qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea")
qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable
Expand All @@ -15,5 +16,6 @@ def registerTypes():
qmlRegisterType(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable
qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController")
qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData")
qmlRegisterType(PySyntaxHighlighter, "ScriptEditor", 1, 0, "PySyntaxHighlighter")

qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D")
263 changes: 258 additions & 5 deletions meshroom/ui/components/scriptEditor.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
from PySide6.QtCore import QObject, Slot

""" Script Editor for Meshroom.
"""
# STD
from io import StringIO
from contextlib import redirect_stdout
import traceback

# Qt
from PySide6 import QtCore, QtGui
from PySide6.QtCore import Property, QObject, Slot, Signal, QSettings


class ScriptEditorManager(QObject):
""" Manages the script editor history and logs.
"""

_GROUP = "ScriptEditor"
_KEY = "script"

def __init__(self, parent=None):
super(ScriptEditorManager, self).__init__(parent=parent)
Expand All @@ -13,23 +25,68 @@ def __init__(self, parent=None):
self._globals = {}
self._locals = {}

# Protected
def _defaultScript(self):
""" Returns the default script for the script editor.
"""
lines = (
"from meshroom.ui import uiInstance\n",
"graph = uiInstance.activeProject.graph",
"for node in graph.nodes:",
" print(node.name)"
)

return "\n".join(lines)

def _lastScript(self):
""" Returns the last script from the user settings.
"""
settings = QSettings()
settings.beginGroup(self._GROUP)
return settings.value(self._KEY)

def _hasPreviousScript(self):
""" Returns whether there is a previous script available.
"""
# If the current index is greater than the first
return self._index > 0

def _hasNextScript(self):
""" Returns whethere there is a new script available to load.
"""
# If the current index is lower than the available indexes
return self._index < (len(self._history) - 1)

# Public
@Slot(str, result=str)
def process(self, script):
""" Execute the provided input script, capture the output from the standard output, and return it. """
# Saves the state if an exception has occured
exception = False

stdout = StringIO()
with redirect_stdout(stdout):
try:
exec(script, self._globals, self._locals)
except Exception as exception:
# Format and print the exception to stdout, which will be captured
print("{}: {}".format(type(exception).__name__, exception))
except Exception:
# Update that we have an exception that is thrown
exception = True
# Print the backtrace
traceback.print_exc(file=stdout)

result = stdout.getvalue().strip()

# Strip out additional part
if exception:
# We know that we're executing the above statement and that caused the exception
# What we want to show to the user is just the part that happened while executing the script
# So just split with the last part and show it to the user
result = result.split("self._locals)", 1)[-1]

# Add the script to the history and move up the index to the top of history stack
self._history.append(script)
self._index = len(self._history)
self.scriptIndexChanged.emit()

return result

Expand All @@ -45,6 +102,7 @@ def getNextScript(self):
If there is no next entry, return an empty string. """
if self._index + 1 < len(self._history) and len(self._history) > 0:
self._index = self._index + 1
self.scriptIndexChanged.emit()
return self._history[self._index]
return ""

Expand All @@ -54,7 +112,202 @@ def getPreviousScript(self):
If there is no previous entry, return an empty string. """
if self._index - 1 >= 0 and self._index - 1 < len(self._history):
self._index = self._index - 1
self.scriptIndexChanged.emit()
return self._history[self._index]
elif self._index == 0 and len(self._history):
return self._history[self._index]
return ""

@Slot(result=str)
def loadLastScript(self):
""" Returns the last executed script from the prefs.
"""
return self._lastScript() or self._defaultScript()

@Slot(str)
def saveScript(self, script):
""" Returns the last executed script from the prefs.
Args:
script (str): The script to save.
"""
settings = QSettings()
settings.beginGroup(self._GROUP)
settings.setValue(self._KEY, script)
settings.sync()

scriptIndexChanged = Signal()

hasPreviousScript = Property(bool, _hasPreviousScript, notify=scriptIndexChanged)
hasNextScript = Property(bool, _hasNextScript, notify=scriptIndexChanged)


class CharFormat(QtGui.QTextCharFormat):
""" The Char format for the syntax.
"""

def __init__(self, color, bold=False, italic=False):
""" Constructor.
"""
super().__init__()

self._color = QtGui.QColor()
self._color.setNamedColor(color)

# Update the Foreground color
self.setForeground(self._color)

# The font characteristics
if bold:
self.setFontWeight(QtGui.QFont.Bold)
if italic:
self.setFontItalic(True)


class PySyntaxHighlighter(QtGui.QSyntaxHighlighter):
"""Syntax highlighter for the Python language.
"""

# Syntax styles that can be shared by all languages
STYLES = {
"keyword" : CharFormat("#9e59b3"), # Purple
"operator" : CharFormat("#2cb8a0"), # Teal
"brace" : CharFormat("#2f807e"), # Dark Aqua
"defclass" : CharFormat("#c9ba49", bold=True), # Yellow
"deffunc" : CharFormat("#4996c9", bold=True), # Blue
"string" : CharFormat("#7dbd39"), # Greeny
"comment" : CharFormat("#8d8d8d", italic=True), # Dark Grayish
"self" : CharFormat("#e6ba43", italic=True), # Yellow
"numbers" : CharFormat("#d47713"), # Orangish
}

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

# Python operators
operators = (
"=",
# Comparison
"==", "!=", "<", "<=", ">", ">=",
# Arithmetic
r"\+", "-", r"\*", "/", "//", r"\%", r"\*\*",
# In-place
r"\+=", "-=", r"\*=", "/=", r"\%=",
# Bitwise
r"\^", r"\|", r"\&", r"\~", r">>", r"<<",
)

# Python braces
braces = (r"\{", r"\}", r"\(", r"\)", r"\[", r"\]")

def __init__(self, parent=None):
""" Constructor.
Keyword Args:
parent (QObject): The QObject parent from the QML side.
"""
super().__init__(parent)

# The Document to highlight
self._document = None

# Build a QRegularExpression for each of the pattern
self._rules = self.__rules()

# Private
def __rules(self):
""" Formatting rules.
"""
# Set of rules accordind to which the highlight should occur
rules = []

# Keyword rules
rules += [(QtCore.QRegularExpression(r"\b" + w + r"\s"), 0, PySyntaxHighlighter.STYLES["keyword"]) for w in PySyntaxHighlighter.keywords]
# Operator rules
rules += [(QtCore.QRegularExpression(o), 0, PySyntaxHighlighter.STYLES["operator"]) for o in PySyntaxHighlighter.operators]
# Braces
rules += [(QtCore.QRegularExpression(b), 0, PySyntaxHighlighter.STYLES["brace"]) for b in PySyntaxHighlighter.braces]

# All other rules
rules += [
# self
(QtCore.QRegularExpression(r'\bself\b'), 0, PySyntaxHighlighter.STYLES["self"]),

# 'def' followed by an identifier
(QtCore.QRegularExpression(r'\bdef\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["deffunc"]),
# 'class' followed by an identifier
(QtCore.QRegularExpression(r'\bclass\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["defclass"]),

# Numeric literals
(QtCore.QRegularExpression(r'\b[+-]?[0-9]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]),
(QtCore.QRegularExpression(r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]),
(QtCore.QRegularExpression(r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]),

# Double-quoted string, possibly containing escape sequences
(QtCore.QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"'), 0, PySyntaxHighlighter.STYLES["string"]),
# Single-quoted string, possibly containing escape sequences
(QtCore.QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'"), 0, PySyntaxHighlighter.STYLES["string"]),

# From '#' until a newline
(QtCore.QRegularExpression(r'#[^\n]*'), 0, PySyntaxHighlighter.STYLES['comment']),
]

return rules

def highlightBlock(self, text):
""" Applies syntax highlighting to the given block of text.
Args:
text (str): The text to highlight.
"""
# Do other syntax formatting
for expression, nth, _format in self._rules:
# fetch the index of the expression in text
match = expression.match(text, 0)
index = match.capturedStart()

while index >= 0:
# We actually want the index of the nth match
index = match.capturedStart(nth)
length = len(match.captured(nth))
self.setFormat(index, length, _format)
# index = expression.indexIn(text, index + length)
match = expression.match(text, index + length)
index = match.capturedStart()

def textDoc(self):
""" Returns the document being highlighted.
"""
return self._document

def setTextDocument(self, document):
""" Sets the document on the Highlighter.
Args:
document (QtQuick.QQuickTextDocument): The document from the QML engine.
"""
# If the same document is provided again
if document == self._document:
return

# Update the class document
self._document = document

# Set the document on the highlighter
self.setDocument(self._document.textDocument())

# Emit that the document is now changed
self.textDocumentChanged.emit()

# Signals
textDocumentChanged = Signal()

# Property
textDocument = Property(QObject, textDoc, setTextDocument, notify=textDocumentChanged)
1 change: 1 addition & 0 deletions meshroom/ui/qml/Application.qml
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,7 @@ Page {
ScriptEditor {
id: scriptEditor
anchors.fill: parent
rootApplication: root

visible: graphEditorPanel.currentTab === 2
}
Expand Down
Loading
Loading