Skip to content

Commit 3e8b736

Browse files
authored
Merge pull request #2587 from alicevision/dev/PythonScriptEditor
[ui] Python Script Editor Improvements
2 parents 6284c38 + 7384db8 commit 3e8b736

File tree

4 files changed

+385
-82
lines changed

4 files changed

+385
-82
lines changed

meshroom/ui/components/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ def registerTypes():
77
from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper
88
from meshroom.ui.components.csvData import CsvData
99
from meshroom.ui.components.geom2D import Geom2D
10+
from meshroom.ui.components.scriptEditor import PySyntaxHighlighter
1011

1112
qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea")
1213
qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable
@@ -15,5 +16,6 @@ def registerTypes():
1516
qmlRegisterType(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable
1617
qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController")
1718
qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData")
19+
qmlRegisterType(PySyntaxHighlighter, "ScriptEditor", 1, 0, "PySyntaxHighlighter")
1820

1921
qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D")

meshroom/ui/components/scriptEditor.py

+258-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
from PySide6.QtCore import QObject, Slot
2-
1+
""" Script Editor for Meshroom.
2+
"""
3+
# STD
34
from io import StringIO
45
from contextlib import redirect_stdout
6+
import traceback
7+
8+
# Qt
9+
from PySide6 import QtCore, QtGui
10+
from PySide6.QtCore import Property, QObject, Slot, Signal, QSettings
11+
512

613
class ScriptEditorManager(QObject):
14+
""" Manages the script editor history and logs.
15+
"""
16+
17+
_GROUP = "ScriptEditor"
18+
_KEY = "script"
719

820
def __init__(self, parent=None):
921
super(ScriptEditorManager, self).__init__(parent=parent)
@@ -13,23 +25,68 @@ def __init__(self, parent=None):
1325
self._globals = {}
1426
self._locals = {}
1527

28+
# Protected
29+
def _defaultScript(self):
30+
""" Returns the default script for the script editor.
31+
"""
32+
lines = (
33+
"from meshroom.ui import uiInstance\n",
34+
"graph = uiInstance.activeProject.graph",
35+
"for node in graph.nodes:",
36+
" print(node.name)"
37+
)
1638

39+
return "\n".join(lines)
40+
41+
def _lastScript(self):
42+
""" Returns the last script from the user settings.
43+
"""
44+
settings = QSettings()
45+
settings.beginGroup(self._GROUP)
46+
return settings.value(self._KEY)
47+
48+
def _hasPreviousScript(self):
49+
""" Returns whether there is a previous script available.
50+
"""
51+
# If the current index is greater than the first
52+
return self._index > 0
53+
54+
def _hasNextScript(self):
55+
""" Returns whethere there is a new script available to load.
56+
"""
57+
# If the current index is lower than the available indexes
58+
return self._index < (len(self._history) - 1)
59+
60+
# Public
1761
@Slot(str, result=str)
1862
def process(self, script):
1963
""" Execute the provided input script, capture the output from the standard output, and return it. """
64+
# Saves the state if an exception has occured
65+
exception = False
66+
2067
stdout = StringIO()
2168
with redirect_stdout(stdout):
2269
try:
2370
exec(script, self._globals, self._locals)
24-
except Exception as exception:
25-
# Format and print the exception to stdout, which will be captured
26-
print("{}: {}".format(type(exception).__name__, exception))
71+
except Exception:
72+
# Update that we have an exception that is thrown
73+
exception = True
74+
# Print the backtrace
75+
traceback.print_exc(file=stdout)
2776

2877
result = stdout.getvalue().strip()
2978

79+
# Strip out additional part
80+
if exception:
81+
# We know that we're executing the above statement and that caused the exception
82+
# What we want to show to the user is just the part that happened while executing the script
83+
# So just split with the last part and show it to the user
84+
result = result.split("self._locals)", 1)[-1]
85+
3086
# Add the script to the history and move up the index to the top of history stack
3187
self._history.append(script)
3288
self._index = len(self._history)
89+
self.scriptIndexChanged.emit()
3390

3491
return result
3592

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

@@ -54,7 +112,202 @@ def getPreviousScript(self):
54112
If there is no previous entry, return an empty string. """
55113
if self._index - 1 >= 0 and self._index - 1 < len(self._history):
56114
self._index = self._index - 1
115+
self.scriptIndexChanged.emit()
57116
return self._history[self._index]
58117
elif self._index == 0 and len(self._history):
59118
return self._history[self._index]
60119
return ""
120+
121+
@Slot(result=str)
122+
def loadLastScript(self):
123+
""" Returns the last executed script from the prefs.
124+
"""
125+
return self._lastScript() or self._defaultScript()
126+
127+
@Slot(str)
128+
def saveScript(self, script):
129+
""" Returns the last executed script from the prefs.
130+
131+
Args:
132+
script (str): The script to save.
133+
"""
134+
settings = QSettings()
135+
settings.beginGroup(self._GROUP)
136+
settings.setValue(self._KEY, script)
137+
settings.sync()
138+
139+
scriptIndexChanged = Signal()
140+
141+
hasPreviousScript = Property(bool, _hasPreviousScript, notify=scriptIndexChanged)
142+
hasNextScript = Property(bool, _hasNextScript, notify=scriptIndexChanged)
143+
144+
145+
class CharFormat(QtGui.QTextCharFormat):
146+
""" The Char format for the syntax.
147+
"""
148+
149+
def __init__(self, color, bold=False, italic=False):
150+
""" Constructor.
151+
"""
152+
super().__init__()
153+
154+
self._color = QtGui.QColor()
155+
self._color.setNamedColor(color)
156+
157+
# Update the Foreground color
158+
self.setForeground(self._color)
159+
160+
# The font characteristics
161+
if bold:
162+
self.setFontWeight(QtGui.QFont.Bold)
163+
if italic:
164+
self.setFontItalic(True)
165+
166+
167+
class PySyntaxHighlighter(QtGui.QSyntaxHighlighter):
168+
"""Syntax highlighter for the Python language.
169+
"""
170+
171+
# Syntax styles that can be shared by all languages
172+
STYLES = {
173+
"keyword" : CharFormat("#9e59b3"), # Purple
174+
"operator" : CharFormat("#2cb8a0"), # Teal
175+
"brace" : CharFormat("#2f807e"), # Dark Aqua
176+
"defclass" : CharFormat("#c9ba49", bold=True), # Yellow
177+
"deffunc" : CharFormat("#4996c9", bold=True), # Blue
178+
"string" : CharFormat("#7dbd39"), # Greeny
179+
"comment" : CharFormat("#8d8d8d", italic=True), # Dark Grayish
180+
"self" : CharFormat("#e6ba43", italic=True), # Yellow
181+
"numbers" : CharFormat("#d47713"), # Orangish
182+
}
183+
184+
# Python keywords
185+
keywords = (
186+
"and", "assert", "break", "class", "continue", "def",
187+
"del", "elif", "else", "except", "exec", "finally",
188+
"for", "from", "global", "if", "import", "in",
189+
"is", "lambda", "not", "or", "pass", "print",
190+
"raise", "return", "try", "while", "yield",
191+
"None", "True", "False",
192+
)
193+
194+
# Python operators
195+
operators = (
196+
"=",
197+
# Comparison
198+
"==", "!=", "<", "<=", ">", ">=",
199+
# Arithmetic
200+
r"\+", "-", r"\*", "/", "//", r"\%", r"\*\*",
201+
# In-place
202+
r"\+=", "-=", r"\*=", "/=", r"\%=",
203+
# Bitwise
204+
r"\^", r"\|", r"\&", r"\~", r">>", r"<<",
205+
)
206+
207+
# Python braces
208+
braces = (r"\{", r"\}", r"\(", r"\)", r"\[", r"\]")
209+
210+
def __init__(self, parent=None):
211+
""" Constructor.
212+
213+
Keyword Args:
214+
parent (QObject): The QObject parent from the QML side.
215+
"""
216+
super().__init__(parent)
217+
218+
# The Document to highlight
219+
self._document = None
220+
221+
# Build a QRegularExpression for each of the pattern
222+
self._rules = self.__rules()
223+
224+
# Private
225+
def __rules(self):
226+
""" Formatting rules.
227+
"""
228+
# Set of rules accordind to which the highlight should occur
229+
rules = []
230+
231+
# Keyword rules
232+
rules += [(QtCore.QRegularExpression(r"\b" + w + r"\s"), 0, PySyntaxHighlighter.STYLES["keyword"]) for w in PySyntaxHighlighter.keywords]
233+
# Operator rules
234+
rules += [(QtCore.QRegularExpression(o), 0, PySyntaxHighlighter.STYLES["operator"]) for o in PySyntaxHighlighter.operators]
235+
# Braces
236+
rules += [(QtCore.QRegularExpression(b), 0, PySyntaxHighlighter.STYLES["brace"]) for b in PySyntaxHighlighter.braces]
237+
238+
# All other rules
239+
rules += [
240+
# self
241+
(QtCore.QRegularExpression(r'\bself\b'), 0, PySyntaxHighlighter.STYLES["self"]),
242+
243+
# 'def' followed by an identifier
244+
(QtCore.QRegularExpression(r'\bdef\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["deffunc"]),
245+
# 'class' followed by an identifier
246+
(QtCore.QRegularExpression(r'\bclass\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["defclass"]),
247+
248+
# Numeric literals
249+
(QtCore.QRegularExpression(r'\b[+-]?[0-9]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]),
250+
(QtCore.QRegularExpression(r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]),
251+
(QtCore.QRegularExpression(r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]),
252+
253+
# Double-quoted string, possibly containing escape sequences
254+
(QtCore.QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"'), 0, PySyntaxHighlighter.STYLES["string"]),
255+
# Single-quoted string, possibly containing escape sequences
256+
(QtCore.QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'"), 0, PySyntaxHighlighter.STYLES["string"]),
257+
258+
# From '#' until a newline
259+
(QtCore.QRegularExpression(r'#[^\n]*'), 0, PySyntaxHighlighter.STYLES['comment']),
260+
]
261+
262+
return rules
263+
264+
def highlightBlock(self, text):
265+
""" Applies syntax highlighting to the given block of text.
266+
267+
Args:
268+
text (str): The text to highlight.
269+
"""
270+
# Do other syntax formatting
271+
for expression, nth, _format in self._rules:
272+
# fetch the index of the expression in text
273+
match = expression.match(text, 0)
274+
index = match.capturedStart()
275+
276+
while index >= 0:
277+
# We actually want the index of the nth match
278+
index = match.capturedStart(nth)
279+
length = len(match.captured(nth))
280+
self.setFormat(index, length, _format)
281+
# index = expression.indexIn(text, index + length)
282+
match = expression.match(text, index + length)
283+
index = match.capturedStart()
284+
285+
def textDoc(self):
286+
""" Returns the document being highlighted.
287+
"""
288+
return self._document
289+
290+
def setTextDocument(self, document):
291+
""" Sets the document on the Highlighter.
292+
293+
Args:
294+
document (QtQuick.QQuickTextDocument): The document from the QML engine.
295+
"""
296+
# If the same document is provided again
297+
if document == self._document:
298+
return
299+
300+
# Update the class document
301+
self._document = document
302+
303+
# Set the document on the highlighter
304+
self.setDocument(self._document.textDocument())
305+
306+
# Emit that the document is now changed
307+
self.textDocumentChanged.emit()
308+
309+
# Signals
310+
textDocumentChanged = Signal()
311+
312+
# Property
313+
textDocument = Property(QObject, textDoc, setTextDocument, notify=textDocumentChanged)

meshroom/ui/qml/Application.qml

+1
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,7 @@ Page {
12691269
ScriptEditor {
12701270
id: scriptEditor
12711271
anchors.fill: parent
1272+
rootApplication: root
12721273

12731274
visible: graphEditorPanel.currentTab === 2
12741275
}

0 commit comments

Comments
 (0)