Merge pull request #2587 from alicevision/dev/PythonScriptEditor

[ui] Python Script Editor Improvements
This commit is contained in:
Fabien Castan 2025-01-10 22:30:46 +01:00 committed by GitHub
commit 3e8b736cf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 386 additions and 83 deletions

View file

@ -7,6 +7,7 @@ def registerTypes():
from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper
from meshroom.ui.components.csvData import CsvData from meshroom.ui.components.csvData import CsvData
from meshroom.ui.components.geom2D import Geom2D from meshroom.ui.components.geom2D import Geom2D
from meshroom.ui.components.scriptEditor import PySyntaxHighlighter
qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea") qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea")
qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable
@ -15,5 +16,6 @@ def registerTypes():
qmlRegisterType(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable qmlRegisterType(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable
qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController") qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController")
qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData") qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData")
qmlRegisterType(PySyntaxHighlighter, "ScriptEditor", 1, 0, "PySyntaxHighlighter")
qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D") qmlRegisterSingletonType(Geom2D, "Meshroom.Helpers", 1, 0, "Geom2D")

View file

@ -1,9 +1,21 @@
from PySide6.QtCore import QObject, Slot """ Script Editor for Meshroom.
"""
# STD
from io import StringIO from io import StringIO
from contextlib import redirect_stdout 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): class ScriptEditorManager(QObject):
""" Manages the script editor history and logs.
"""
_GROUP = "ScriptEditor"
_KEY = "script"
def __init__(self, parent=None): def __init__(self, parent=None):
super(ScriptEditorManager, self).__init__(parent=parent) super(ScriptEditorManager, self).__init__(parent=parent)
@ -13,23 +25,68 @@ class ScriptEditorManager(QObject):
self._globals = {} self._globals = {}
self._locals = {} 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) @Slot(str, result=str)
def process(self, script): def process(self, script):
""" Execute the provided input script, capture the output from the standard output, and return it. """ """ 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() stdout = StringIO()
with redirect_stdout(stdout): with redirect_stdout(stdout):
try: try:
exec(script, self._globals, self._locals) exec(script, self._globals, self._locals)
except Exception as exception: except Exception:
# Format and print the exception to stdout, which will be captured # Update that we have an exception that is thrown
print("{}: {}".format(type(exception).__name__, exception)) exception = True
# Print the backtrace
traceback.print_exc(file=stdout)
result = stdout.getvalue().strip() 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 # Add the script to the history and move up the index to the top of history stack
self._history.append(script) self._history.append(script)
self._index = len(self._history) self._index = len(self._history)
self.scriptIndexChanged.emit()
return result return result
@ -45,6 +102,7 @@ class ScriptEditorManager(QObject):
If there is no next entry, return an empty string. """ If there is no next entry, return an empty string. """
if self._index + 1 < len(self._history) and len(self._history) > 0: if self._index + 1 < len(self._history) and len(self._history) > 0:
self._index = self._index + 1 self._index = self._index + 1
self.scriptIndexChanged.emit()
return self._history[self._index] return self._history[self._index]
return "" return ""
@ -54,7 +112,202 @@ class ScriptEditorManager(QObject):
If there is no previous entry, return an empty string. """ If there is no previous entry, return an empty string. """
if self._index - 1 >= 0 and self._index - 1 < len(self._history): if self._index - 1 >= 0 and self._index - 1 < len(self._history):
self._index = self._index - 1 self._index = self._index - 1
self.scriptIndexChanged.emit()
return self._history[self._index] return self._history[self._index]
elif self._index == 0 and len(self._history): elif self._index == 0 and len(self._history):
return self._history[self._index] return self._history[self._index]
return "" 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)

View file

@ -1269,6 +1269,7 @@ Page {
ScriptEditor { ScriptEditor {
id: scriptEditor id: scriptEditor
anchors.fill: parent anchors.fill: parent
rootApplication: root
visible: graphEditorPanel.currentTab === 2 visible: graphEditorPanel.currentTab === 2
} }

View file

@ -9,22 +9,81 @@ import Utils 1.0
import Qt.labs.platform 1.0 as Platform import Qt.labs.platform 1.0 as Platform
import ScriptEditor 1.0
Item { Item {
id: root id: root
function formatInput(text) { // Defines the parent or the root Application of which this script editor is a part of
var lines = text.split("\n") property var rootApplication: undefined;
for (let i = 0; i < lines.length; ++i) {
lines[i] = ">>> " + lines[i] Component {
id: clearConfirmationDialog
MessageDialog {
title: "Clear history"
preset: "Warning"
text: "This will clear all history of executed scripts."
helperText: "Are you sure you would like to continue?."
standardButtons: Dialog.Ok | Dialog.Cancel
onClosed: destroy()
} }
return lines.join("\n")
} }
function processScript() { function replace(text, string, replacement) {
output.clear() /**
var ret = ScriptEditorManager.process(input.text) * Replaces all occurences of the string in the text
output.text = formatInput(input.text) + "\n\n" + ret * @param text - overall text
* @param string - the string to be replaced in the text
* @param replacement - the replacement of the string
*/
// Split with the string
let lines = text.split(string)
// Return the overall text joined with the replacement
return lines.join(replacement)
}
function formatInput(text) {
/**
* Formats the text to be displayed as the input script executed
*/
// Replace the text to be RichText Supportive
return "<font color=#868686>> Input:<br>" + replace(text, "\n", "<br>") + "</font><br>"
}
function formatOutput(text) {
/**
* Formats the text to be displayed as the result of the script executed
*/
// Replace the text to be RichText Supportive
return "<font color=#49a1f3>> Result:<br>" + replace(text, "\n", "<br>") + "</font><br>"
}
function clearHistory() {
/**
* Clears all of the executed history from the script editor
*/
ScriptEditorManager.clearHistory()
input.clear() input.clear()
output.clear()
}
function processScript(text = "") {
// Use either the provided/selected or the entire script
text = text || input.text
// Execute the process and fetch back the return for it
var ret = ScriptEditorManager.process(text)
// Append the input script and the output result to the output console
output.append(formatInput(text) + formatOutput(ret))
// Save the entire script after executing the commands
ScriptEditorManager.saveScript(input.text)
} }
function loadScript(fileUrl) { function loadScript(fileUrl) {
@ -83,13 +142,9 @@ Item {
RowLayout { RowLayout {
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
Item {
Layout.fillWidth: true
}
MaterialToolButton { MaterialToolButton {
font.pointSize: 13 font.pointSize: 13
text: MaterialIcons.download text: MaterialIcons.file_open
ToolTip.text: "Load Script" ToolTip.text: "Load Script"
onClicked: { onClicked: {
@ -99,7 +154,7 @@ Item {
MaterialToolButton { MaterialToolButton {
font.pointSize: 13 font.pointSize: 13
text: MaterialIcons.upload text: MaterialIcons.save
ToolTip.text: "Save Script" ToolTip.text: "Save Script"
onClicked: { onClicked: {
@ -107,40 +162,13 @@ Item {
} }
} }
Item {
width: executeButton.width
}
MaterialToolButton {
id: executeButton
font.pointSize: 13
text: MaterialIcons.slideshow
ToolTip.text: "Execute Script"
onClicked: {
root.processScript()
}
}
MaterialToolButton {
font.pointSize: 13
text: MaterialIcons.cancel_presentation
ToolTip.text: "Clear Output Window"
onClicked: {
output.clear()
}
}
Item {
width: executeButton.width
}
MaterialToolButton { MaterialToolButton {
font.pointSize: 13 font.pointSize: 13
text: MaterialIcons.history text: MaterialIcons.history
ToolTip.text: "Get Previous Script" ToolTip.text: "Get Previous Script"
enabled: ScriptEditorManager.hasPreviousScript;
onClicked: { onClicked: {
var ret = ScriptEditorManager.getPreviousScript() var ret = ScriptEditorManager.getPreviousScript()
@ -156,6 +184,8 @@ Item {
text: MaterialIcons.update text: MaterialIcons.update
ToolTip.text: "Get Next Script" ToolTip.text: "Get Next Script"
enabled: ScriptEditorManager.hasNextScript;
onClicked: { onClicked: {
var ret = ScriptEditorManager.getNextScript() var ret = ScriptEditorManager.getNextScript()
@ -168,46 +198,57 @@ Item {
MaterialToolButton { MaterialToolButton {
font.pointSize: 13 font.pointSize: 13
text: MaterialIcons.backspace text: MaterialIcons.delete_sweep
ToolTip.text: "Clear History" ToolTip.text: "Clear History"
onClicked: { onClicked: {
ScriptEditorManager.clearHistory() // Confirm from the user before clearing out any history
input.clear() const confirmationDialog = clearConfirmationDialog.createObject(rootApplication ? rootApplication : root);
output.clear() confirmationDialog.accepted.connect(clearHistory);
confirmationDialog.open();
}
}
Item {
width: executeButton.width;
}
MaterialToolButton {
id: executeButton
font.pointSize: 13
text: MaterialIcons.play_arrow
ToolTip.text: "Execute Script"
onClicked: {
root.processScript()
} }
} }
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
} }
}
RowLayout { MaterialToolButton {
Label { font.pointSize: 13
text: "Input" text: MaterialIcons.backspace
font.bold: true ToolTip.text: "Clear Output Window"
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
}
Label { onClicked: {
text: "Output" output.clear()
font.bold: true }
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
} }
} }
RowLayout { MSplitView {
Layout.fillWidth: true id: scriptSplitView;
Layout.fillHeight: true Layout.fillHeight: true;
width: root.width Layout.fillWidth: true;
orientation: Qt.Horizontal;
// Input Text Area -- Holds the input scripts to be executed
Rectangle { Rectangle {
id: inputArea id: inputArea
Layout.fillHeight: true SplitView.preferredWidth: root.width / 2;
Layout.fillWidth: true
color: palette.base color: palette.base
@ -254,7 +295,7 @@ Item {
width: parent.width width: parent.width
height: parent.height height: parent.height
contentWidth: width contentWidth: width
contentHeight: height contentHeight: input.contentHeight;
anchors.left: lineNumbers.right anchors.left: lineNumbers.right
anchors.top: parent.top anchors.top: parent.top
@ -266,13 +307,8 @@ Item {
TextArea.flickable: TextArea { TextArea.flickable: TextArea {
id: input id: input
text: { text: ScriptEditorManager.loadLastScript()
var str = "from meshroom.ui import uiInstance\n\n"
str += "graph = uiInstance.activeProject.graph\n"
str += "for node in graph.nodes:\n"
str += " print(node.name)"
return str
}
font: lineNumbers.textMetrics.font font: lineNumbers.textMetrics.font
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
@ -287,7 +323,7 @@ Item {
Keys.onPressed: function(event) { Keys.onPressed: function(event) {
if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && event.modifiers === Qt.ControlModifier) { if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && event.modifiers === Qt.ControlModifier) {
root.processScript() root.processScript(input.selectedText)
} }
} }
} }
@ -299,7 +335,8 @@ Item {
} }
} }
} }
// Output Text Area -- Shows the output for the executed script(s)
Rectangle { Rectangle {
id: outputArea id: outputArea
Layout.fillHeight: true Layout.fillHeight: true
@ -311,7 +348,7 @@ Item {
width: parent.width width: parent.width
height: parent.height height: parent.height
contentWidth: width contentWidth: width
contentHeight: height contentHeight: output.contentHeight;
ScrollBar.vertical: MScrollBar {} ScrollBar.vertical: MScrollBar {}
@ -323,9 +360,19 @@ Item {
padding: 0 padding: 0
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
wrapMode: Text.WordWrap
textFormat: Text.RichText
} }
} }
} }
// Syntax Highlights for the Input Area for Python Based Syntax
PySyntaxHighlighter {
id: syntaxHighlighter
// The document to highlight
textDocument: input.textDocument
}
} }
} }
} }