diff --git a/__init__.py b/__init__.py index dcd7d2e..c248234 100644 --- a/__init__.py +++ b/__init__.py @@ -95,11 +95,26 @@ def __init__(self, moduleItem, attributes, *, mainWindow=None, **kwargs): layout.setColumnStretch(1, 1) self.setLayout(layout) - globEnv = self.mainWindow.getModuleGlobalEnv() - globEnv.update({"module": ModuleWrapper(self.moduleItem.module), "ch": self.moduleItem.module.ch, "chset": self.moduleItem.module.chset}) + def executor(cmd, env=None): + envUI = self.mainWindow.getEnvUI() + RuntimeModule.env = envUI # update environment for runtime modules + + localEnv = dict(envUI) + localEnv.update(self.moduleItem.module.getEnv()) + localEnv.update(env or {}) + + with captureOutput(self.mainWindow.logWidget): + try: + exec(cmd, localEnv) + except Exception as e: + print("Error: "+str(e)) + self.mainWindow.showLog() + else: + self.updateWidgets() + return localEnv for a in attributes: - templateWidget = widgets.TemplateWidgets[a.template](env=globEnv) + templateWidget = widgets.TemplateWidgets[a.template](executor=executor) nameWidget = QLabel(a.name) self._attributeAndWidgets.append((a, nameWidget, templateWidget)) @@ -109,7 +124,6 @@ def __init__(self, moduleItem, attributes, *, mainWindow=None, **kwargs): self.updateWidgetStyle(idx) templateWidget.somethingChanged.connect(lambda idx=idx: self.widgetOnChange(idx)) - templateWidget.needUpdateUI.connect(self.updateWidgets) nameWidget.setAlignment(Qt.AlignRight) nameWidget.setStyleSheet("QLabel:hover:!pressed{ background-color: #666666; }") @@ -157,10 +171,17 @@ def nameContextMenuEvent(self, event, attrWidgetIndex): if attr.connect: menu.addAction("Break connection", Callback(self.disconnectAttr, attrWidgetIndex)) - menu.addSeparator() + + menu.addSeparator() menu.addAction("Edit data", Callback(self.editData, attrWidgetIndex)) + menu.addSeparator() menu.addAction("Edit expression", Callback(self.editExpression, attrWidgetIndex)) + + if attr.expression: + menu.addAction("Evaluate expression", Callback(self.updateWidget, attrWidgetIndex)) + menu.addAction("Clear expression", Callback(self.clearExpression, attrWidgetIndex)) + menu.addSeparator() menu.addAction("Expose", Callback(self.exposeAttr, attrWidgetIndex)) menu.addSeparator() @@ -168,46 +189,59 @@ def nameContextMenuEvent(self, event, attrWidgetIndex): menu.popup(event.globalPos()) + def _wrapper(f): + def inner(self, attrWidgetIndex, *args, **kwargs): + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + with captureOutput(self.mainWindow.logWidget): + try: + return f(self, attrWidgetIndex, *args, **kwargs) + + except Exception as e: + print("Error: {}.{}: {}".format(self.moduleItem.module.name, attr.name, str(e))) + self.mainWindow.showLog() + + return inner + + @_wrapper def widgetOnChange(self, attrWidgetIndex): attr, _, widget = self._attributeAndWidgets[attrWidgetIndex] - newData = widget.getJsonData() - oldData = copyJson(attr.data) - attr.data = newData + previousAttrsData = {id(otherAttr): copyJson(otherAttr.data) for otherAttr in self.moduleItem.module.getAttributes()} - if attr.connect: - _, srcAttr = self.moduleItem.module.findConnectionSourceForAttribute(attr) - if srcAttr: - srcAttr.updateFromAttribute(attr) - else: - if not Attribute.isDataSame(oldData, newData): # compare without value and expression - self.moduleItem.module.modified = True - self.moduleItem.emitDataChanged() + runtimeAttr = RuntimeAttribute(self.moduleItem.module, attr) + widgetData = widget.getJsonData() + runtimeAttr.data = copyJson(widgetData) + runtimeAttr.push() + + modifiedAttrs = [] + for otherAttr in self.moduleItem.module.getAttributes(): + RuntimeAttribute(self.moduleItem.module, otherAttr).pull() + + if otherAttr.data != previousAttrsData[id(otherAttr)]: + otherAttr.modified = True + modifiedAttrs.append(otherAttr) - if oldData != newData and not attr.expression: # compare with default value - attr.modified = True - self.updateWidgetStyle(attrWidgetIndex) + for idx, (otherAttr, _, _) in enumerate(self._attributeAndWidgets): # update attributes' widgets + if otherAttr in modifiedAttrs: + self.updateWidget(idx) + self.updateWidgetStyle(idx) + if attr.data != widgetData: + widget.blockSignals(True) + widget.setJsonData(attr.data) + widget.blockSignals(False) + self.updateWidgetStyle(attrWidgetIndex) + + @_wrapper def updateWidget(self, attrWidgetIndex): attr, _, widget = self._attributeAndWidgets[attrWidgetIndex] - with captureOutput(self.mainWindow.logWidget): - error = None - try: - self.moduleItem.module.resolveExpression(attr) - self.moduleItem.module.resolveConnection(attr) - widget.setJsonData(attr.data) - except AttributeExpressionError as e: - error = "Error: " + str(e) - except Exception as e: - error = "Error: '{}' has invalid or incompatible JSON data".format(attr.name) - - if error: - print(error) - attr.data = widget.getDefaultData() - widget.setJsonData(attr.data) - self.mainWindow.showLog() - self.mainWindow.logWidget.ensureCursorVisible() + runtimeAttr = RuntimeAttribute(self.moduleItem.module, attr) + runtimeAttr.pull() + + widget.blockSignals(True) + widget.setJsonData(runtimeAttr.data) + widget.blockSignals(False) def updateWidgets(self): for i in range(len(self._attributeAndWidgets)): @@ -221,15 +255,16 @@ def updateWidgetStyle(self, attrWidgetIndex): if attr.connect: tooltip.append("Connect: "+attr.connect) if attr.expression: - tooltip.append("Expression:\n"+attr.expression) + tooltip.append("Expression:\n" + attr.expression) - if attr.connect: - style = "TemplateWidget {border: 4px solid #6e6e39; background-color: #6e6e39}" - elif attr.expression: - style = "TemplateWidget {border: 4px solid #632094; background-color: #632094}" - - if attr.expression and attr.connect: # both, invalid - style = "TemplateWidget {border: 4px solid #781656; background-color: #781656}" + if attr.connect and not attr.expression: # only connection + style = "TemplateWidget { border: 4px solid #6e6e39; background-color: #6e6e39 }" + + elif attr.expression and not attr.connect: # only expression + style = "TemplateWidget { border: 4px solid #632094; background-color: #632094 }" + + elif attr.expression and attr.connect: # both + style = "TemplateWidget { border: 4px solid rgb(0,0,0,0); background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2:0, stop: 0 #6e6e39, stop: 1 #632094);}" nameWidget.setText(attr.name+("*" if attr.modified else "")) @@ -271,23 +306,29 @@ def save(data): def editExpression(self, attrWidgetIndex): def save(text): attr.expression = text - self.updateWidget(attrWidgetIndex) + self.updateWidgets() self.updateWidgetStyle(attrWidgetIndex) attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + words = set(self.mainWindow.getEnvUI().keys()) | set(self.moduleItem.module.getEnv().keys()) placeholder = '# Example: value = ch("../someAttr") + 1 or data["items"] = [1,2,3]' - w = widgets.EditTextDialog(attr.expression, title="Edit expression for '{}'".format(attr.name), placeholder=placeholder, python=True) + w = widgets.EditTextDialog(attr.expression, title="Edit expression for '{}'".format(attr.name), placeholder=placeholder, words=words, python=True) w.saved.connect(save) w.show() + def clearExpression(self, attrWidgetIndex): + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + attr.expression = "" + self.updateWidgetStyle(attrWidgetIndex) + def resetAttr(self, attrWidgetIndex): - attr, _, widget = self._attributeAndWidgets[attrWidgetIndex] + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] tmp = widgets.TemplateWidgets[attr.template]() attr.data = tmp.getDefaultData() attr.connect = "" - widget.setJsonData(attr.data) + self.updateWidget(attrWidgetIndex) self.updateWidgetStyle(attrWidgetIndex) def disconnectAttr(self, attrWidgetIndex): @@ -296,10 +337,9 @@ def disconnectAttr(self, attrWidgetIndex): self.updateWidgetStyle(attrWidgetIndex) def connectAttr(self, connect, attrWidgetIndex): - attr, _, widget = self._attributeAndWidgets[attrWidgetIndex] + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] attr.connect = connect - self.moduleItem.module.resolveConnection(attr) - widget.setJsonData(attr.data) + self.updateWidget(attrWidgetIndex) self.updateWidgetStyle(attrWidgetIndex) class AttributesTabWidget(QTabWidget): @@ -562,7 +602,10 @@ def __init__(self, module, **kwargs): self.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled) def clone(self): - return ModuleItem(self.module.copy()) + item = ModuleItem(self.module.copy()) + for i in range(self.childCount()): + item.addChild(self.child(i).clone()) + return item def data(self, column, role): if column == 0: # name @@ -650,6 +693,9 @@ def setData(self, column, role, value): if column == 0: if role == Qt.EditRole: newName = replaceSpecialChars(value).strip() + if self.parent(): + existingNames = set([ch.name for ch in self.parent().module.getChildren()]) + newName = findUniqueName(newName, existingNames) connections = self._saveConnections(self.module) # rename in connections self.module.name = newName @@ -732,7 +778,6 @@ def dragEnterEvent(self, event): if event.mouseButtons() == Qt.MiddleButton: self.dragItems = self.selectedItems() - self.dragParents = [item.parent() for item in self.dragItems] else: event.ignore() @@ -743,6 +788,11 @@ def dragMoveEvent(self, event): event.setDropAction(Qt.CopyAction) def dropEvent(self, event): + for item in self.dragItems: + if item.parent(): + item.parent().module.modified = True + item.parent().emitDataChanged() + super().dropEvent(event) if event.mimeData().hasUrls(): @@ -760,23 +810,20 @@ def dropEvent(self, event): print(e) print("Error '{}': invalid module".format(path)) self.mainWindow.showLog() - self.mainWindow.logWidget.ensureCursorVisible() - - if self.dragItems: - for oldParent, item in zip(self.dragParents, self.dragItems): - if oldParent: - oldParent.module.removeChild(item.module) + else: + for item in self.dragItems: + if item.module.parent: # remove from old parent + item.module.parent.removeChild(item.module) newParent = item.parent() if newParent: - if item.module in newParent.module.getChildren(): - newParent.module.removeChild(item.module) - idx = newParent.indexOfChild(item) newParent.module.insertChild(idx, item.module) + + newParent.module.modified = True + newParent.emitDataChanged() self.dragItems = [] - self.dragParents = [] def makeItemFromModule(self, module): item = ModuleItem(module) @@ -844,7 +891,6 @@ def importModule(self): except ET.ParseError: print("Error '{}': invalid module".format(filePath)) self.mainWindow.showLog() - self.mainWindow.logWidget.ensureCursorVisible() def saveModule(self): selectedItems = self.selectedItems() @@ -942,10 +988,12 @@ def muteModule(self): def duplicateModule(self): newItems = [] for item in self.selectedItems(): - parent = item.parent() - newItem = self.makeItemFromModule(item.module.copy()) + if item.parent(): + existingNames = set([ch.name for ch in item.parent().module.getChildren()]) + newItem.module.name = findUniqueName(item.module.name, existingNames) + parent = item.parent() if parent: parent.addChild(newItem) parent.module.addChild(newItem.module) @@ -973,6 +1021,9 @@ def removeModule(self): if parent: parent.removeChild(item) parent.module.removeChild(item.module) + + parent.module.modified = True + parent.emitDataChanged() else: self.invisibleRootItem().removeChild(item) @@ -1150,6 +1201,7 @@ def downBtnClicked(self): w.nameWidget.setText(self.nameWidget.text()) w.attrConnect = self.attrConnect w.attrExpression = self.attrExpression + w.attrCallback = self.attrCallback w.attrModified = self.attrModified self.deleteLater() @@ -1162,6 +1214,7 @@ def upBtnClicked(self): w.nameWidget.setText(self.nameWidget.text()) w.attrConnect = self.attrConnect w.attrExpression = self.attrExpression + w.attrCallback = self.attrCallback w.attrModified = self.attrModified self.deleteLater() @@ -1417,14 +1470,14 @@ def generateCompletionWords(self): if not self.moduleItem: return - words = list(self.mainWindow.getModuleGlobalEnv().keys()) - words.extend(list(widgets.WidgetsAPI.keys())) + words = set(self.mainWindow.getEnvUI().keys()) | set(self.moduleItem.module.getEnv().keys()) for a in self.moduleItem.module.getAttributes(): - words.append("@" + a.name) - words.append("@set_" + a.name) + words.add("@" + a.name) + words.add("@" + a.name + "_data") + words.add("@set_" + a.name) - self.editorWidget.words = set(words) + self.editorWidget.words = words class LogHighligher(QSyntaxHighlighter): def __init__(self, parent): @@ -1567,7 +1620,7 @@ def __init__(self): self.runBtn = QPushButton("Run!") self.runBtn.setStyleSheet("background-color: #3e4f89") - self.runBtn.clicked.connect(self.runModulesBtnClicked) + self.runBtn.clicked.connect(self.runModuleClicked) self.runBtn.hide() self.infoWidget = QTextBrowser() @@ -1636,7 +1689,7 @@ def getMenu(self): helpMenu.addAction("Documentation", self.showDocumenation) return menu - + def copyToolCode(self): selectedItems = self.treeWidget.selectedItems() if selectedItems: @@ -1745,30 +1798,23 @@ def showLog(self): if sizes[-1] < 10: sizes[-1] = 200 self.vsplitter.setSizes(sizes) + self.logWidget.ensureCursorVisible() - def getModuleGlobalEnv(self): - env = {"beginProgress": self.progressBarWidget.beginProgress, - "stepProgress": self.progressBarWidget.stepProgress, - "endProgress": self.progressBarWidget.endProgress, - "currentTabIndex": self.attributesTabWidget.currentIndex()} - - for k,v in getModuleDefaultEnv().items(): - env[k] = v - - for k, f in widgets.WidgetsAPI.items(): - env[k] = f - - return env + def getEnvUI(self): + return {"beginProgress": self.progressBarWidget.beginProgress, + "stepProgress": self.progressBarWidget.stepProgress, + "endProgress": self.progressBarWidget.endProgress, + "currentTabIndex": self.attributesTabWidget.currentIndex()} - def runModulesBtnClicked(self): + def runModuleClicked(self): if DCC == "maya": cmds.undoInfo(ock=True) # run in undo chunk - self.runModules() + self.runModule() cmds.undoInfo(cck=True) else: - self.runModules() + self.runModule() - def runModules(self): + def runModule(self): def uiCallback(mod): self.progressBarWidget.stepProgress(self.progressCounter, mod.getPath()) self.progressCounter += 1 @@ -1806,8 +1852,10 @@ def getChildrenCount(item): muted = currentItem.module.muted currentItem.module.muted = False + RuntimeModule.env = self.getEnvUI() # for runtime module + try: - currentItem.module.run(self.getModuleGlobalEnv(), uiCallback=uiCallback) + currentItem.module.run(env=self.getEnvUI(), uiCallback=uiCallback) except Exception: printErrorStack() finally: @@ -1838,7 +1886,7 @@ def RigBuilderTool(spec, child=None, *, size=None): # spec can be full path, rel w.setWindowTitle("Rig Builder Tool - {}".format(module.getPath())) w.treeWidget.addTopLevelItem(w.treeWidget.makeItemFromModule(module)) w.treeWidget.setCurrentItem(w.treeWidget.topLevelItem(0)) - + w.codeEditorWidget.hide() w.treeWidget.hide() @@ -1850,9 +1898,11 @@ def RigBuilderTool(spec, child=None, *, size=None): # spec can be full path, rel w.resize(size[0], size[1]) else: # auto size w.adjustSize() - + return w +ModulesAPI.update(widgets.WidgetsAPI) + if not os.path.exists(RigBuilderLocalPath): os.makedirs(RigBuilderLocalPath+"/modules") diff --git a/classes.py b/classes.py index 3bce61b..c22d664 100644 --- a/classes.py +++ b/classes.py @@ -5,6 +5,9 @@ import json import uuid import xml.etree.ElementTree as ET +from .utils import copyJson + +ModulesAPI = {} # updated at the end if sys.version_info.major > 2: RigBuilderPath = os.path.dirname(__file__) @@ -24,36 +27,6 @@ def getUidFromFile(path): if r: return r.group(1) -def smartConversion(x): - v = None - try: - v = int(x) - except ValueError: - try: - v = float(x) - except ValueError: - v = str(x) - return v - -def copyJson(data): - if data is None: - return None - - elif type(data) in [list, tuple]: - return [copyJson(x) for x in data] - - elif type(data) == dict: - return {k:copyJson(data[k]) for k in data} - - elif type(data) in [int, float, bool, str]: - return data - - elif sys.version_info.major < 3 and type(data) is unicode: # compatibility with python 2.7 - return data - - else: - raise TypeError("Data of {} type is not JSON compatible: {}".format(type(data), str(data))) - def calculateRelativePath(path, root): path = os.path.normpath(path) path = path.replace(os.path.normpath(root)+"\\", "") @@ -103,46 +76,13 @@ def __init__(self, name, data={}, category="", template="", connect="", expressi self.modified = False # used by UI def copy(self): - return Attribute(self.name, copyJson(self.data), self.category, self.template, self.connect, self.expression) - - def __eq__(self, other): - if not isinstance(other, Attribute): - return False - - return self.name == other.name and\ - self.data == other.data and\ - self.category == other.category and\ - self.template == other.template # don't compare connections - - def hasDefault(self): - return "default" in self.data + return Attribute(self.name, self.data, self.category, self.template, self.connect, self.expression) def getDefaultValue(self): - if self.hasDefault(): - return self.data[self.data["default"]] + return self.data[self.data["default"]] def setDefaultValue(self, v): - if self.hasDefault(): - self.data[self.data["default"]] = v - - @staticmethod - def isDataSame(a, b): - a = dict(a) - b = dict(b) - - if "default" in a and "default" in b: - a.pop(a["default"]) # remove default value - b.pop(b["default"]) - - if a == b: - return True - - def updateFromAttribute(self, otherAttr): - # copy default value if any - if self.hasDefault() and otherAttr.hasDefault(): - self.setDefaultValue(copyJson(otherAttr.getDefaultValue())) - else: - self.data = copyJson(otherAttr.data) + self.data[self.data["default"]] = v def toXml(self, *, keepConnection=True): attrs = [("name", self.name), @@ -153,9 +93,9 @@ def toXml(self, *, keepConnection=True): attrsStr = " ".join(["{}=\"{}\"".format(k, v) for k, v in attrs]) data = dict(self.data) # here data can have additional keys for storing custom data - if self.expression and keepConnection: # expressions are the part of neighbor modules, save them as connections + if self.expression: data["_expression"] = self.expression - + header = "" return header.format(attribs=attrsStr, data=json.dumps(data)) @@ -208,14 +148,6 @@ def copy(self): module.muted = self.muted return module - def __eq__(self, other): - if not isinstance(other, Module): - return False - return self.uid == other.uid and\ - self.name == other.name and\ - self.runCode == other.runCode and\ - self.loadedFrom == other.loadedFrom - def clearChildren(self): self._children = [] @@ -366,6 +298,7 @@ def update(self): origAttr.data = foundAttr.data origAttr.connect = foundAttr.connect + origAttr.expression = foundAttr.expression newAttributes.append(origAttr) @@ -472,8 +405,7 @@ def _listConnections(currentModule): def findConnectionSourceForAttribute(self, attr): if self.parent and attr.connect: srcModule, srcAttr = self.parent.findModuleAndAttributeByPath(attr.connect) - if srcAttr: - return srcModule.findConnectionSourceForAttribute(srcAttr) + return srcModule, srcAttr return self, attr @@ -481,7 +413,7 @@ def findModuleAndAttributeByPath(self, path): ''' Returns (module, attribute) by path, where path is /a/b/c, where c is attr, a/b is a parent relative path ''' - *moduleList, attr = path.split("/") + *moduleList, attrName = path.split("/") currentParent = self for module in moduleList: @@ -495,94 +427,55 @@ def findModuleAndAttributeByPath(self, path): elif module == ".": continue - found = currentParent.findChild(module) - if found: - currentParent = found + ch = currentParent.findChild(module) + if ch: + currentParent = ch else: - return (None, None) - - found = currentParent.findAttribute(attr) - return (currentParent, found) if found else (currentParent, None) - - def resolveConnection(self, attr): - if not attr.connect: - return + raise AttributeResolverError("Cannot resolve '{}' path".format(path)) - srcMod, srcAttr = self.findConnectionSourceForAttribute(attr) + attr = currentParent.findAttribute(attrName) + if not attr: + raise AttributeResolverError("Cannot find '{}' attribute".format(path)) - if srcAttr is not attr: - if attr.template != srcAttr.template: - raise AttributeResolverError("{}: '{}' has incompatible connection template".format(self.name, attr.name)) + return currentParent, attr - try: - srcMod.resolveExpression(srcAttr) - attr.updateFromAttribute(srcAttr) - - except TypeError: - raise AttributeResolverError("{}: '{}' data is not JSON serializable".format(self.name, attr.name)) - - else: - raise AttributeResolverError("{}: cannot resolve connection for '{}' which is '{}'".format(self.name, attr.name, attr.connect)) - - def resolveExpression(self, attr): + def executeExpression(self, attr): if not attr.expression: return - env = {"module": ModuleWrapper(self), "ch": self.ch, "data": attr.data, "value": copyJson(attr.getDefaultValue())} + localEnv = dict(self.getEnv()) + localEnv.update({"data": attr.data, "value": attr.getDefaultValue()}) + try: - exec(attr.expression, env) + exec(attr.expression, localEnv) except Exception as e: - raise AttributeExpressionError("{}: '{}' has invalid expression: {}".format(self.name, attr.name, str(e))) + raise AttributeExpressionError("Invalid expression: {}".format(str(e))) else: - attr.setDefaultValue(env["value"]) + attr.setDefaultValue(localEnv["value"]) - def ch(self, path, key=None): - mod, attr = self.findModuleAndAttributeByPath(path) - if attr: - _, attr = mod.findConnectionSourceForAttribute(attr) - if not key: - return copyJson(attr.getDefaultValue()) - else: - return AttributeWrapper(self, attr).data().get(key) - else: - raise AttributeResolverError("Attribute '{}' not found".format(path)) + def getEnv(self): + mod = RuntimeModule(self) - def chset(self, path, value, key=None): - mod, attr = self.findModuleAndAttributeByPath(path) - if attr: - _, attr = mod.findConnectionSourceForAttribute(attr) - if not key: - attr.setDefaultValue(value) - else: - AttributeWrapper(self, attr).data()[key] = value - else: - raise AttributeResolverError("Attribute '{}' not found".format(path)) + env = dict(ModulesAPI) + env.update({"module": mod, + "ch": mod.ch, + "chdata": mod.chdata, + "chset": mod.chset}) + return env - def run(self, env, *, uiCallback=None): + def run(self, *, env=None, uiCallback=None): if self.muted: return - localEnv = { - "module": ModuleWrapper(self), - "ch": self.ch, - "chset": self.chset - } - - for k in env: - if k not in localEnv: # don't overwrite locals - localEnv[k] = env[k] - - ModuleWrapper.env = dict(env) # update environment for runtime modules + localEnv = dict(env or {}) + localEnv.update(self.getEnv()) attrPrefix = "attr_" for attr in self._attributes: - self.resolveExpression(attr) - self.resolveConnection(attr) # connection rewrites data - - attrWrapper = AttributeWrapper(self, attr) - localEnv[attrPrefix+attr.name] = attrWrapper.get() - localEnv[attrPrefix+"set_"+attr.name] = attrWrapper.set - localEnv[attrPrefix+attr.name+"_data"] = attrWrapper.data() + runtimeAttr = RuntimeAttribute(self, attr) + localEnv[attrPrefix+attr.name] = runtimeAttr.get() + localEnv[attrPrefix+"set_"+attr.name] = runtimeAttr.set + localEnv[attrPrefix+attr.name+"_data"] = DataAccessor(RuntimeAttribute(self, attr)) print("{} is running...".format(self.getPath())) @@ -595,7 +488,7 @@ def run(self, env, *, uiCallback=None): pass for ch in self._children: - ch.run(env, uiCallback=uiCallback) + ch.run(env=env, uiCallback=uiCallback) return localEnv @@ -621,7 +514,7 @@ def findUids(path): return uids -# used inside modules in scripts +# Runtime classes class Dict(dict): def __init__(self): pass @@ -632,34 +525,68 @@ def __getattr__(self, name): def __setattr__(self, name, value): self[name] = value -class AttributeWrapper(object): +class RuntimeAttribute(object): def __init__(self, module, attr): self._attr = attr self._module = module - def set(self, v): - try: - vcopy = copyJson(v) - except TypeError: - raise CopyJsonError("Cannot set non-JSON data for '{}' attribute (got {})".format(self._attr.name, v)) + def pull(self): + if self._attr.connect: + srcMod, srcAttr = self._module.findConnectionSourceForAttribute(self._attr) - srcAttr = self._attr - if self._attr.connect and self._module.parent: - _, srcAttr = self._module.findConnectionSourceForAttribute(self._attr) + RuntimeAttribute(srcMod, srcAttr).pull() + srcMod.executeExpression(srcAttr) + self._attr.setDefaultValue(copyJson(srcAttr.getDefaultValue())) - default = srcAttr.data.get("default") - if default: - srcAttr.data[default] = vcopy - else: - srcAttr.data = vcopy + self._module.executeExpression(self._attr) # run expression on top of connection + + def push(self): + if self._attr.connect: + srcMod, srcAttr = self._module.findConnectionSourceForAttribute(self._attr) + + srcAttr.setDefaultValue(copyJson(self._attr.getDefaultValue())) + RuntimeAttribute(srcMod, srcAttr).push() def get(self): - default = self._attr.data.get("default") - return copyJson(self._attr.data[default] if default else self._attr.data) + self.pull() + return copyJson(self._attr.getDefaultValue()) - def data(self): - return self._attr.data + def set(self, value): + try: + valueCopy = copyJson(value) + except TypeError: + raise CopyJsonError("Cannot set non-JSON data (got {})".format(value)) + + self._attr.setDefaultValue(valueCopy) + self.push() + @property + def data(self): # data is local + return self._attr.data + + @data.setter + def data(self, value): # data is local + self._attr.data = value + +class DataAccessor(): # for accessing data with @_data suffix inside a module's code + def __init__(self, runtimeAttr): + self._runtimeAttr = runtimeAttr + + def __getitem__(self, name): + self._runtimeAttr.pull() + return self._runtimeAttr.data[name] + + def __setitem__(self, name, value): + self._runtimeAttr.data[name] = value + self._runtimeAttr.push() + + def __str__(self): + items = [] + for k in self._runtimeAttr.data: + self._runtimeAttr.pull() + items.append("{}: {}".format(k, self._runtimeAttr.data[k])) + return "\n".join(items) + class AttrsWrapper(object): # attributes getter/setter def __init__(self, module): self._module = module @@ -668,7 +595,7 @@ def __getattr__(self, name): module = object.__getattribute__(self, "_module") attr = module.findAttribute(name) if attr: - return AttributeWrapper(self._module, attr) + return RuntimeAttribute(self._module, attr) else: raise AttributeError("Attribute '{}' not found".format(name)) @@ -679,19 +606,11 @@ def __setattr__(self, name, value): module = object.__getattribute__(self, "_module") attr = module.findAttribute(name) if attr: - AttributeWrapper(self._module, attr).set(value) + RuntimeAttribute(self._module, attr).set(value) else: raise AttributeError("Attribute '{}' not found".format(name)) -''' -How to use wrappers inside scripts. -module.attr.someAttr.set(10) -module.attr.someAttr = 5 -module.parent().attr.someAttr.set(20) -print(@attr) # module.attr.attr.get() -@set_attr(30) # module.attr.attr.set(30) -''' -class ModuleWrapper(object): +class RuntimeModule(object): glob = Dict() # global memory env = {} # default environment for module scripts @@ -704,30 +623,25 @@ def __init__(self, specOrModule): # spec is path or module self.attr = AttrsWrapper(self._module) - def __eq__(self, other): - if not isinstance(other, ModuleWrapper): - return False - return self._module == other._module - def name(self): return self._module.name def child(self, nameOrIndex): if type(nameOrIndex) == int: - return ModuleWrapper(self._module.getChildren()[nameOrIndex]) + return RuntimeModule(self._module.getChildren()[nameOrIndex]) elif type(nameOrIndex) == str: m = self._module.findChild(nameOrIndex) if m: - return ModuleWrapper(m) + return RuntimeModule(m) else: raise ModuleNotFoundError("Child module '{}' not found".format(nameOrIndex)) def children(self): - return [ModuleWrapper(ch) for ch in self._module.getChildren()] + return [RuntimeModule(ch) for ch in self._module.getChildren()] def parent(self): - return ModuleWrapper(self._module.parent) if self._module.parent else None + return RuntimeModule(self._module.parent) if self._module.parent else None def muted(self): return self._module.muted @@ -742,10 +656,22 @@ def path(self): return self._module.getPath() def ch(self, path, key=None): - return self._module.ch(path, key) - + mod, attr = self._module.findModuleAndAttributeByPath(path) + if key: + return RuntimeAttribute(mod, attr).data[key] + else: + return RuntimeAttribute(mod, attr).get() + + def chdata(self, path): + mod, attr = self._module.findModuleAndAttributeByPath(path) + return copyJson(RuntimeAttribute(mod, attr).data) + def chset(self, path, value, key=None): - self._module.chset(path, value, key) + mod, attr = self._module.findModuleAndAttributeByPath(path) + if not key: + RuntimeAttribute(mod, attr).set(value) + else: + RuntimeAttribute(mod, attr).data[key] = value def run(self): muted = self._module.muted @@ -753,32 +679,31 @@ def run(self): env = {} try: - env = self._module.run(ModuleWrapper.env) + env = self._module.run(env=RuntimeModule.env) except: raise finally: self._module.muted = muted return env -def getModuleDefaultEnv(): - def printError(msg): - raise RuntimeError(msg) - - def printWarning(msg): - print("Warning: "+msg) - - def exitModule(): - raise ExitModuleException() - - env = {"module":None, # setup in Module.run - "Module": ModuleWrapper, - "ch": None, # setup in Module.run - "chset": None, # setup in Module.run - "copyJson": copyJson, - "exit": exitModule, - "error": printError, - "warning": printWarning} - - return env +def printError(msg): + raise RuntimeError(msg) + +def printWarning(msg): + print("Warning: "+msg) + +def exitModule(): + raise ExitModuleException() + +ModulesAPI.update({ + "module":None, # updated at runtime + "Module": RuntimeModule, + "ch": None, # updated at runtime + "chdata": None, # updated at runtime + "chset": None, # updated at runtime + "copyJson": copyJson, + "exit": exitModule, + "error": printError, + "warning": printWarning}) Module.updateUidsCache() diff --git a/jsonWidget.py b/jsonWidget.py index 258d3cd..16166f9 100644 --- a/jsonWidget.py +++ b/jsonWidget.py @@ -5,7 +5,7 @@ import re import os -from .utils import clamp, getActions, centerWindow, setActionsLocalShortcut, SimpleUndo, SearchReplaceDialog, JsonColors +from .utils import clamp, getActions, centerWindow, setActionsLocalShortcut, SimpleUndo, SearchReplaceDialog, JsonColors, findUniqueName RootDirectory = os.path.dirname(__file__) @@ -158,18 +158,6 @@ def data(self, _, role): else: return super().data(0, role) - def findUniqueKey(self, key="key"): - def keyExists(k): - return any([self.child(i).data(0, JsonItem.KeyRole) == k for i in range(self.childCount())]) - - keyNoNum = re.sub(r"\d+$", "", key) # remove trailing numbers - newKey = key - i = 1 - while keyExists(newKey): - newKey = keyNoNum + str(i) - i += 1 - return newKey - def getPath(self, path=""): parent = self.parent() if not parent: @@ -234,7 +222,7 @@ def __init__(self, data=None, **kwargs): self.setReadOnly(self._readOnly) if data: - self.fromJsonList([data]) + self.loadFromJsonList(data if type(data) == list else [data]) def getMenu(self): menu = QMenu(self) @@ -376,7 +364,9 @@ def editKey(self): if key is not None: newKey, ok = QInputDialog.getText(self, "Edit key", "Key:", text=key) if ok: - item.setData(0, item.KeyRole, item.parent().findUniqueKey(newKey)) + existingKeys = set([item.parent().child(i).data(0, item.KeyRole) for i in range(item.parent().childCount())]) + newKey = findUniqueName(newKey, existingKeys) + item.setData(0, item.KeyRole, newKey) # undo def f(): @@ -420,14 +410,14 @@ def loadFromFile(self): self.clear() with open(path, "r") as f: d = json.load(f) - self.fromJsonList([d]) + self.loadFromJsonList([d]) def importFile(self): path, _ = QFileDialog.getOpenFileName(self, "Import JSON", "", "JSON (*.json)") if path: with open(path, "r") as f: d = json.load(f) - self.fromJsonList([d]) + self.loadFromJsonList([d]) def setRootItem(self, item=None): if item and item.jsonType in [item.ListType, item.DictType]: @@ -466,23 +456,43 @@ def revealSelected(self): self.scrollToItem(selectedItems[-1], QAbstractItemView.PositionAtCenter) def editItemData(self, item=None): - def saveCallback(item, data): - newItem = self.setItemJson(item, data) - self.itemChanged.emit(item, 0) + def saveCallback(item, newData): + if item: + newItem = self.setItemJson(item, newData) + self.itemChanged.emit(item, 0) - # undo - def f(): - parent = newItem.parent() or self.invisibleRootItem() - idx = parent.indexOfChild(newItem) - parent.removeChild(newItem) - parent.insertChild(idx, item.clone()) - self._undoSystem.push("EditData", f) + # undo + def f(): + parent = newItem.parent() or self.invisibleRootItem() + idx = parent.indexOfChild(newItem) + parent.removeChild(newItem) + parent.insertChild(idx, item.clone()) + self._undoSystem.push("EditData", f) + + else: + if type(newData) != list: + newData = [newData] + + oldData = self.toJsonList() + self.clear() + for d in newData: + item = self.itemFromJson(d) + self.addTopLevelItem(item) + self.itemChanged.emit(item, 0) + + # undo + def f(): + self.clear() + for d in oldData: + self.addTopLevelItem(self.itemFromJson(d)) + self._undoSystem.push("EditData", f) item = item or self.selectedItem() - if item: - dlg = EditJsonTextWindow(self.itemToJson(item), readOnly=self._readOnly) - dlg.saved.connect(lambda data: saveCallback(item, data)) - dlg.exec_() + data = self.itemToJson(item) if item else self.toJsonList() + + dlg = EditJsonTextWindow(data, readOnly=self._readOnly) + dlg.saved.connect(lambda data: saveCallback(item, data)) + dlg.exec_() def moveItem(self, direction): selectedItems = self.selectedItems() @@ -551,7 +561,8 @@ def addItem(self, json, parentItem=None, *, insertIndex=None): if parentItem and parentItem is not self.invisibleRootItem(): if parentItem.jsonType == parentItem.DictType: - key = parentItem.findUniqueKey() + existingKeys = set([parentItem.child(i).data(0, parentItem.KeyRole) for i in range(parentItem.childCount())]) + key = findUniqueName("key", existingKeys) item.setData(0, item.KeyRole, key) elif parentItem.jsonType != parentItem.ListType: return @@ -624,14 +635,26 @@ def toJsonList(self): return [self.itemToJson(self.topLevelItem(i)) for i in range(self.topLevelItemCount())] def fromJsonList(self, dataList): + items = [] for d in dataList: - self.addTopLevelItem(self.itemFromJson(d)) + item = self.itemFromJson(d) + self.addTopLevelItem(item) + items.append(item) + return items - self.expandItem(self.invisibleRootItem(), True) + def loadFromJsonList(self, dataList): + newItems = self.fromJsonList(dataList) + for item in newItems: + self.expandItem(item, True) + + # undo + def f(): + for item in newItems: + self.invisibleRootItem().removeChild(item) + self._undoSystem.push("Load", f) + self.dataLoaded.emit() - self._undoSystem.flush() - def itemToJson(self, item): if item.jsonType == item.ListType: json = [] diff --git a/modules/example.xml b/modules/example.xml index 513d0c5..bb28a91 100644 --- a/modules/example.xml +++ b/modules/example.xml @@ -5,10 +5,12 @@ print("lineAttr:", @lineAttr, type(@lineAttr)) beginProgress("Some slow operation", 10) for i in range(10): stepProgress(i) - time.sleep(0.2) + time.sleep(0.1) endProgress() -@set_out_time(time.strftime(time.ctime())) +@set_out_other("Hello world") + +chset("/out_lst", [1,2,3]) # children access ch = module.child("child") # or module.child(0) @@ -16,26 +18,36 @@ ch.run() ]]> - +

You can use html markup here

", "default": "text"}]]>
- - + + + + + +
+print("lineAttr = {}".format(ch("../lineAttr")))]]> - + + + + + - - + + + + + + diff --git a/runStandalone.py b/runStandalone.py new file mode 100644 index 0000000..0041116 --- /dev/null +++ b/runStandalone.py @@ -0,0 +1,17 @@ +import sys +import os + +from PySide2.QtCore import * +from PySide2.QtGui import * +from PySide2.QtWidgets import * + +app = QApplication([]) + +os.environ["RIG_BUILDER_DCC"] = "standalone" +import rigBuilder + +with open(rigBuilder.RigBuilderPath+"/stylesheet.css") as r: + app.setStyleSheet(r.read()) + +rigBuilder.mainWindow.show() +app.exec_() diff --git a/utils.py b/utils.py index ce68950..2af8716 100644 --- a/utils.py +++ b/utils.py @@ -1,12 +1,13 @@ import sys import re from contextlib import contextmanager +import json from PySide2.QtGui import * from PySide2.QtCore import * from PySide2.QtWidgets import * -JsonColors = {"none": QColor("#000000"), +JsonColors = {"none": QColor("#AAAAAA"), "bool": QColor("#CDEB8B"), "true": QColor("#82C777"), "false": QColor("#CC6666"), @@ -43,11 +44,51 @@ def clamp(val, low, high): def replaceSpecialChars(text): return re.sub("[^a-zA-Z0-9_]", "_", text) +def findUniqueName(name, existingNames): + nameNoNum = re.sub(r"\d+$", "", name) # remove trailing numbers + newName = name + i = 1 + while newName in existingNames: + newName = nameNoNum + str(i) + i += 1 + return newName + def replacePairs(pairs, text): for k, v in pairs: text = re.sub(k, v, text) return text +def smartConversion(x): + try: + return json.loads(x) + except ValueError: + return str(x) + +def fromSmartConversion(x): + if sys.version_info.major > 2: + return json.dumps(x) if not isinstance(x, str) else x + else: + return json.dumps(x) if type(x) not in [str, unicode] else x + +def copyJson(data): + if data is None: + return None + + elif type(data) in [list, tuple]: + return [copyJson(x) for x in data] + + elif type(data) == dict: + return {k:copyJson(data[k]) for k in data} + + elif type(data) in [int, float, bool, str]: + return data + + elif sys.version_info.major < 3 and type(data) is unicode: # compatibility with python 2.7 + return data + + else: + raise TypeError("Data of '{}' type is not JSON compatible: {}".format(type(data), str(data))) + @contextmanager def captureOutput(stream): default_stdout = sys.stdout diff --git a/widgets.py b/widgets.py index 2242957..d573630 100644 --- a/widgets.py +++ b/widgets.py @@ -20,25 +20,18 @@ if DCC == "maya": import maya.cmds as cmds -def smartConversion(x): - try: - return json.loads(x) - except ValueError: - return str(x) - -def fromSmartConversion(x): - if sys.version_info.major > 2: - return json.dumps(x) if not isinstance(x, str) else x - else: - return json.dumps(x) if type(x) not in [str, unicode] else x - class TemplateWidget(QFrame): somethingChanged = Signal() - needUpdateUI = Signal() - def __init__(self, env=None, **kwargs): - super(TemplateWidget, self).__init__(**kwargs) - self.env = env or {} # used to pass data to widgets + def __init__(self, *, executor=None, **kwargs): + super().__init__(**kwargs) + self.executor = executor or self._defaultExecutor # used to execute commands + + def _defaultExecutor(self, cmd, env=None): + localEnv = dict(WidgetsAPI) + localEnv.update(env or {}) + exec(cmd, localEnv) + return localEnv def getDefaultData(self): return self.getJsonData() @@ -52,7 +45,7 @@ def setJsonData(self, data): class EditTextDialog(QDialog): saved = Signal(str) # emitted when user clicks OK - def __init__(self, text="", *, title="Edit", placeholder="", python=False): + def __init__(self, text="", *, title="Edit", placeholder="", words=None, python=False): super().__init__(parent=QApplication.activeWindow()) self.setWindowTitle(title) @@ -68,6 +61,7 @@ def __init__(self, text="", *, title="Edit", placeholder="", python=False): self.textWidget.setWordWrapMode(QTextOption.NoWrap) else: self.textWidget = CodeEditorWidget() + self.textWidget.words = words or [] self.textWidget.setPlaceholderText(placeholder) self.textWidget.setPlainText(text) @@ -159,22 +153,21 @@ def save(text): self.buttonCommand = text self.somethingChanged.emit() - editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder='chset("/someAttr", 1)', python=True) + words = list(self.executor("").keys()) + + editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder='chset("/someAttr", 1)', words=words, python=True) editText.saved.connect(save) editText.show() def buttonClicked(self): if self.buttonCommand: - localEnv = dict(self.env) - def f(): - exec(self.buttonCommand, localEnv) - self.needUpdateUI.emit() # update UI + self.executor(self.buttonCommand) f() def getDefaultData(self): - return {"command": "module.attr.someAttr.set(1)", + return {"command": 'chset("/someAttr", 1)', "label": "Press me", "default": "label"} @@ -233,11 +226,9 @@ def editItems(self): items = ";".join([self.comboBox.itemText(i) for i in range(self.comboBox.count())]) newItems, ok = QInputDialog.getText(self, "Rig Builder", "Items separated with ';'", QLineEdit.Normal, items) if ok and newItems: - self.comboBox.clear() - for i, item in enumerate(newItems.split(";")): - self.comboBox.addItem(item.strip()) - idx = self.comboBox.count()-1 - self.comboBox.setItemData(idx, jsonColor(smartConversion(item)), Qt.ForegroundRole) + value = self.getJsonData() + value["items"] = [smartConversion(item.strip()) for item in newItems.split(";") if item.strip()] + self.setJsonData(value) self.somethingChanged.emit() def clearItems(self): @@ -267,9 +258,15 @@ def getJsonData(self): def setJsonData(self, value): self.comboBox.clear() + skip = [] for i, item in enumerate(value["items"]): + if item in skip: # don't add duplicates + continue + self.comboBox.addItem(fromSmartConversion(item)) self.comboBox.setItemData(i, jsonColor(item), Qt.ForegroundRole) + skip.append(item) + if value["current"] in value["items"]: self.comboBox.setCurrentIndex(value["items"].index(value["current"])) @@ -320,6 +317,7 @@ def __init__(self, **kwargs): self.minValue = 0 self.maxValue = 100 self.validator = 0 + self.value = "" layout = QHBoxLayout() self.setLayout(layout) @@ -337,28 +335,30 @@ def __init__(self, **kwargs): layout.addWidget(self.textWidget) layout.addWidget(self.sliderWidget) - def colorizeText(self): - text = self.textWidget.text().strip() - color = jsonColor(smartConversion(text)) - self.textWidget.setStyleSheet("color: {}".format(color.name())) + def colorizeValue(self): + color = jsonColor(self.value) + self.textWidget.setStyleSheet("QLineEdit {{ color: {} }}".format(color.name())) def textChanged(self): text = self.textWidget.text().strip() if self.validator: self.sliderWidget.setValue(float(text)*100) - self.colorizeText() + self.value = smartConversion(text) + self.colorizeValue() self.somethingChanged.emit() def sliderValueChanged(self, v): v /= 100.0 if self.validator == 1: # int v = round(v) + self.value = v self.textWidget.setText(str(v)) self.somethingChanged.emit() def textContextMenuEvent(self, event): menu = self.textWidget.createStandardContextMenu() + menu.addSeparator() menu.addAction("Options...", self.optionsClicked) menu.popup(event.globalPos()) @@ -373,7 +373,7 @@ def optionsClicked(self): self.setJsonData(self.getJsonData()) def getJsonData(self): - return {"value": smartConversion(self.textWidget.text().strip()), + return {"value": self.value, "default": "value", "min": self.minValue, "max": self.maxValue, @@ -383,6 +383,7 @@ def setJsonData(self, data): self.validator = data.get("validator", 0) self.minValue = int(data.get("min") or LineEditTemplateWidget.defaultMin) self.maxValue = int(data.get("max") or LineEditTemplateWidget.defaultMax) + self.value = data.get("value", "") if self.validator == 1: self.textWidget.setValidator(QIntValidator()) @@ -396,13 +397,13 @@ def setJsonData(self, data): if self.maxValue: self.sliderWidget.setMaximum(self.maxValue*100) - if data["value"]: - self.sliderWidget.setValue(float(data["value"])*100) + if self.value: + self.sliderWidget.setValue(float(self.value)*100) else: self.sliderWidget.hide() - self.textWidget.setText(fromSmartConversion(data["value"])) - self.colorizeText() + self.textWidget.setText(fromSmartConversion(self.value)) + self.colorizeValue() class LineEditAndButtonTemplateWidget(TemplateWidget): def __init__(self, **kwargs): @@ -428,6 +429,7 @@ def __init__(self, **kwargs): value = path or value'''} self.buttonCommand = defaultCmd["command"] + self.value = "" layout = QHBoxLayout() self.setLayout(layout) @@ -443,13 +445,13 @@ def __init__(self, **kwargs): layout.addWidget(self.textWidget) layout.addWidget(self.buttonWidget) - def colorizeText(self): - text = self.textWidget.text().strip() - color = jsonColor(smartConversion(text)) - self.textWidget.setStyleSheet("color: {}".format(color.name())) + def colorizeValue(self): + color = jsonColor(self.value) + self.textWidget.setStyleSheet("QLineEdit {{ color: {} }}".format(color.name())) def textChanged(self): - self.colorizeText() + self.value = smartConversion(self.textWidget.text().strip()) + self.colorizeValue() self.somethingChanged.emit() def buttonContextMenuEvent(self, event): @@ -481,37 +483,71 @@ def editCommand(self): def save(text): self.buttonCommand = text self.somethingChanged.emit() - - editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder="Your python command...", python=True) + + words = list(self.executor("").keys()) + + editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder="Your python command...", words=words, python=True) editText.saved.connect(save) editText.show() def buttonClicked(self): if self.buttonCommand: - env = dict(self.env) - env["value"] = smartConversion(self.textWidget.text().strip()) - def f(): - exec(self.buttonCommand, env) - self.textWidget.setText(fromSmartConversion(env["value"])) + env = {"value": smartConversion(self.textWidget.text().strip())} + outEnv = self.executor(self.buttonCommand, env) + self.value = outEnv["value"] + self.textWidget.setText(fromSmartConversion(self.value)) self.somethingChanged.emit() f() def getJsonData(self): - return {"value": smartConversion(self.textWidget.text().strip()), + return {"value": self.value, "buttonCommand": self.buttonCommand, "buttonLabel": self.buttonWidget.text(), "default": "value"} - def setCustomText(self, value): - self.textWidget.setText(fromSmartConversion(value)) - def setJsonData(self, data): - self.textWidget.setText(fromSmartConversion(data["value"])) + self.value = data["value"] + self.textWidget.setText(fromSmartConversion(self.value)) self.buttonCommand = data["buttonCommand"] self.buttonWidget.setText(data["buttonLabel"]) - self.colorizeText() + self.colorizeValue() + +class ListBoxItem(QListWidgetItem): + ValueRole = Qt.UserRole + 1 + + def __init__(self, value): + super().__init__() + self._value = value + self.setFlags(self.flags() | Qt.ItemIsEditable) + + def clone(self): + return ListBoxItem(copyJson(self._value)) + + def data(self, role): + if role == Qt.ForegroundRole: + return jsonColor(self._value) + + elif role == Qt.DisplayRole: + return fromSmartConversion(self._value) + + elif role == Qt.EditRole: + return fromSmartConversion(self._value) # always edit as string + + elif role == ListBoxItem.ValueRole: + return self._value + + return super().data(role) + + def setData(self, role, value): + if role == Qt.EditRole: + self._value = smartConversion(value) + + elif role == ListBoxItem.ValueRole: + self._value = value + + super().setData(role, value) class ListBoxTemplateWidget(TemplateWidget): def __init__(self, **kwargs): @@ -522,7 +558,9 @@ def __init__(self, **kwargs): layout.setContentsMargins(QMargins()) self.listWidget = QListWidget() - self.listWidget.itemDoubleClicked.connect(self.itemDoubleClicked) + self.listWidget.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.listWidget.itemSelectionChanged.connect(self.somethingChanged) + self.listWidget.itemChanged.connect(self.itemChanged) self.listWidget.contextMenuEvent = self.listContextMenuEvent layout.addWidget(self.listWidget, alignment=Qt.AlignLeft|Qt.AlignTop) @@ -533,19 +571,31 @@ def listContextMenuEvent(self, event): menu.addAction("Append", self.appendItem) menu.addAction("Remove", self.removeItem) menu.addAction("Edit", self.editItem) - menu.addAction("Sort", self.listWidget.sortItems) + + def f(): + self.listWidget.sortItems() + self.somethingChanged.emit() + menu.addAction("Sort", f) + menu.addSeparator() if DCC in ["maya"]: dccLabel = DCC.capitalize() menu.addAction("Get selected from "+dccLabel, Callback(self.getFromDCC, False)) menu.addAction("Add selected from "+dccLabel, Callback(self.getFromDCC, True)) - menu.addAction("Select in "+dccLabel, self.selectInDCC) + menu.addAction("Select in "+dccLabel, Callback(self.selectInDCC, False)) + menu.addAction("Select all in "+dccLabel, self.selectInDCC) + menu.addSeparator() menu.addAction("Clear", self.clearItems) menu.popup(event.globalPos()) + def itemChanged(self, item): + self.listWidget.closePersistentEditor(item) + self.somethingChanged.emit() + self.resizeWidget() + def resizeWidget(self): width = self.listWidget.sizeHintForColumn(0) + 50 height = 0 @@ -560,14 +610,13 @@ def editItem(self): if ok and newItems: self.listWidget.clear() for x in newItems.split(";"): - item = QListWidgetItem(x.strip()) - item.setForeground(jsonColor(smartConversion(x))) + item = ListBoxItem(smartConversion(x.strip())) self.listWidget.addItem(item) self.somethingChanged.emit() self.resizeWidget() - def selectInDCC(self): - items = [self.listWidget.item(i).text() for i in range(self.listWidget.count())] + def selectInDCC(self, allItems=True): + items = [self.listWidget.item(i).text() for i in range(self.listWidget.count()) if allItems or self.listWidget.item(i).isSelected()] if DCC == "maya": cmds.select(items) @@ -578,9 +627,7 @@ def getFromDCC(self, add=False): def updateUI(nodes): for n in nodes: - item = QListWidgetItem(n) - item.setForeground(jsonColor(n)) - self.listWidget.addItem(item) + self.listWidget.addItem(ListBoxItem(n)) self.resizeWidget() self.somethingChanged.emit() @@ -589,15 +636,17 @@ def updateUI(nodes): updateUI(nodes) def clearItems(self): - self.listWidget.clear() - self.resizeWidget() - self.somethingChanged.emit() + ok = QMessageBox.question(self, "Rig Builder", "Really clear all items?", QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) == QMessageBox.Yes + if ok: + self.listWidget.blockSignals(True) # avoid emitting signals by selecting items + self.listWidget.clear() + self.listWidget.blockSignals(False) + self.somethingChanged.emit() + self.resizeWidget() def appendItem(self): - text = "newItem%d"%(self.listWidget.count()+1) - self.listWidget.addItem(text) - item = self.listWidget.item(self.listWidget.count()-1) - item.setForeground(jsonColor(text)) + text = "item%d"%(self.listWidget.count()+1) + self.listWidget.addItem(ListBoxItem(text)) self.resizeWidget() self.somethingChanged.emit() @@ -606,27 +655,25 @@ def removeItem(self): self.resizeWidget() self.somethingChanged.emit() - def itemDoubleClicked(self, item): - newText, ok = QInputDialog.getText(self, "Rig Builder", "New text", QLineEdit.Normal, item.text()) - if ok: - item.setText(newText) - item.setForeground(jsonColor(smartConversion(newText))) - self.resizeWidget() - self.somethingChanged.emit() - def getDefaultData(self): - return {"items": ["a", "b"], "default": "items"} + return {"items": ["a", "b"], "current":0, "selected":[], "default": "items"} def getJsonData(self): - return {"items": [smartConversion(self.listWidget.item(i).text()) for i in range(self.listWidget.count())], + return {"items": [self.listWidget.item(i).data(ListBoxItem.ValueRole) for i in range(self.listWidget.count())], + "selected": [self.listWidget.row(item) for item in self.listWidget.selectedItems()], "default": "items"} def setJsonData(self, value): self.listWidget.clear() + for v in value["items"]: - item = QListWidgetItem(fromSmartConversion(v)) - item.setForeground(jsonColor(v)) - self.listWidget.addItem(item) + self.listWidget.addItem(ListBoxItem(v)) + + for i in value.get("selected", []): + item = self.listWidget.item(i) + if item: + item.setSelected(True) + self.resizeWidget() class RadioButtonTemplateWidget(TemplateWidget): @@ -653,7 +700,10 @@ def contextMenuEvent(self, event): columnsMenu = QMenu("Columns", self) for n in RadioButtonTemplateWidget.Columns: - columnsMenu.addAction(str(n) + " columns", Callback(self.setColumns, n)) + action = columnsMenu.addAction(str(n) + " columns", Callback(self.setColumns, n)) + if n == self.numColumns: + action.setCheckable(True) + action.setChecked(True) menu.addMenu(columnsMenu) menu.popup(event.globalPos()) @@ -666,7 +716,7 @@ def setColumns(self, n): def colorizeButtons(self): for b in self.buttonsGroupWidget.buttons(): - b.setStyleSheet("background-color: #2a6931" if b.isChecked() else "") + b.setStyleSheet("QRadioButton {background-color: #2a6931}" if b.isChecked() else "") def buttonClicked(self, b): self.colorizeButtons() @@ -698,25 +748,20 @@ def getJsonData(self): def setJsonData(self, value): gridLayout = self.layout() - self.clearButtons() self.numColumns = value["columns"] gridLayout.setDefaultPositioning(self.numColumns, Qt.Horizontal) - row = 0 - column = 0 for i, item in enumerate(value["items"]): - if i % self.numColumns == 0 and i > 0: - row += 1 - column = 0 - button = QRadioButton(item) - gridLayout.addWidget(button, row, column) + gridLayout.addWidget(button, i//self.numColumns, i%self.numColumns) self.buttonsGroupWidget.addButton(button) self.buttonsGroupWidget.setId(button, i) - column += 1 + + if value["current"] not in range(len(value["items"])): + value["current"] = 0 self.buttonsGroupWidget.buttons()[value["current"]].setChecked(True) self.colorizeButtons() @@ -822,8 +867,7 @@ def resizeWidget(self): self.tableWidget.setFixedHeight(clamp(height, headerHeight+100, 500)) def clearAll(self): - ok = QMessageBox.question(self, "Rig Builder", "Really remove all elements?", - QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) == QMessageBox.Yes + ok = QMessageBox.question(self, "Rig Builder", "Really remove all items?", QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) == QMessageBox.Yes if ok: self.tableWidget.clearContents() self.tableWidget.setRowCount(1) @@ -940,33 +984,103 @@ class VectorTemplateWidget(TemplateWidget): def __init__(self, **kwargs): super().__init__(**kwargs) - layout = QHBoxLayout() + self.vectorDim = 3 + self.numColumns = 3 + self.precision = 4 + self.widgets = [] + + layout = QGridLayout() self.setLayout(layout) layout.setContentsMargins(QMargins()) - self.xWidget = QLineEdit() - self.xWidget.setValidator(QDoubleValidator()) - self.xWidget.editingFinished.connect(self.somethingChanged.emit) - - self.yWidget = QLineEdit() - self.yWidget.setValidator(QDoubleValidator()) - self.yWidget.editingFinished.connect(self.somethingChanged.emit) + self.setJsonData(self.getDefaultData()) + + def createMenu(self): + menu = QMenu(self) - self.zWidget = QLineEdit() - self.zWidget.setValidator(QDoubleValidator()) - self.zWidget.editingFinished.connect(self.somethingChanged.emit) + vectorDimMenu = menu.addMenu("Dimension") + for size in range(2, 17): + action = vectorDimMenu.addAction(str(size)+"D", Callback(self.setSizes, size, self.numColumns)) + if size == self.vectorDim: + action.setCheckable(True) + action.setChecked(True) + + numColumnsMenu = menu.addMenu("Columns") + for size in range(2, 8): + action = numColumnsMenu.addAction(str(size), Callback(self.setSizes, self.vectorDim, size)) + if size == self.numColumns: + action.setCheckable(True) + action.setChecked(True) + + precisionMenu = menu.addMenu("Precision") + for n in range(0, 8): + action = precisionMenu.addAction(str(n), Callback(self.setPrecision, n)) + if n == self.precision: + action.setCheckable(True) + action.setChecked(True) + + return menu - layout.addWidget(self.xWidget) - layout.addWidget(self.yWidget) - layout.addWidget(self.zWidget) + def contextMenuEvent(self, event): + menu = self.createMenu() + menu.popup(event.globalPos()) + + def setSizes(self, vectorDim, numColumns): + self.vectorDim = vectorDim + self.numColumns = numColumns + self.setJsonData(self.getJsonData()) + self.somethingChanged.emit() + + def setPrecision(self, prec): + self.precision = prec + self.setJsonData(self.getJsonData()) + self.somethingChanged.emit() + + def getDefaultData(self): + return {"value": [0.0, 0.0, 0.0], "default": "value", "dimension": self.vectorDim, "columns": self.numColumns, "precision": self.precision} def getJsonData(self): - return {"value": [float(self.xWidget.text() or 0), float(self.yWidget.text() or 0), float(self.zWidget.text() or 0)], "default": "value"} + return {"value": [float(w.text() or 0.0) for w in self.widgets], + "default": "value", + "dimension": self.vectorDim, + "precision": self.precision, + "columns": self.numColumns} def setJsonData(self, value): - self.xWidget.setText(str(value["value"][0])) - self.yWidget.setText(str(value["value"][1])) - self.zWidget.setText(str(value["value"][2])) + def widgetContextMenu(event, w): + stdMenu = w.createStandardContextMenu() + stdMenu.addSeparator() + + menu = self.createMenu() + for a in menu.actions(): + stdMenu.addAction(a) + stdMenu.popup(event.globalPos()) + + for w in self.widgets: + w.hide() + + self.widgets = [] + + layout = self.layout() + clearLayout(layout) + + self.numColumns = value.get("columns", self.numColumns) + layout.setDefaultPositioning(self.numColumns, Qt.Horizontal) + + self.vectorDim = value.get("dimension", self.vectorDim) + self.precision = value.get("precision", self.precision) + + validator = QDoubleValidator() + validator.setDecimals(self.precision) + + for i in range(self.vectorDim): + v = value["value"][i] if i < len(value["value"]) else 0.0 + widget = QLineEdit(str(round(v, self.precision))) + widget.setValidator(validator) + widget.editingFinished.connect(self.somethingChanged) + widget.contextMenuEvent = lambda event, w=widget: widgetContextMenu(event, w) + layout.addWidget(widget, i//self.numColumns, i%self.numColumns) + self.widgets.append(widget) def listLerp(lst1, lst2, coeff): return [p1*(1-coeff) + p2*coeff for p1, p2 in zip(lst1, lst2)] @@ -1088,13 +1202,6 @@ def itemChange(self, change, value): elif value.y() < CurveScene.MaxY: value.setY(CurveScene.MaxY) - if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged: - scene = self.scene() - scene.calculateCVs() - for view in scene.views(): - if type(view) == CurveView: - view.somethingChanged.emit() - return super().itemChange(change, value) class CurveScene(QGraphicsScene): @@ -1139,31 +1246,28 @@ def mouseDoubleClickEvent(self, event): item.setPos(pos) self.addItem(item) - self.calculateCVs() - - for view in self.views(): - if type(view) == CurveView: - view.somethingChanged.emit() - def mousePressEvent(self, event): + self._oldCvs = self.cvs[:] + if event.button() == Qt.RightButton: - somethingChanged = False for item in self.selectedItems(): if item.fixedX is None: # don't remove tips self.removeItem(item) - somethingChanged = True - - if somethingChanged: - self.calculateCVs() - - for view in self.views(): - if type(view) == CurveView: - view.somethingChanged.emit() event.accept() else: super().mousePressEvent(event) + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + + self.calculateCVs() + + if self.cvs != self._oldCvs: + for view in self.views(): + if type(view) == CurveView: + view.somethingChanged.emit() + def calculateCVs(self): self.cvs = [] @@ -1187,7 +1291,7 @@ def calculateCVs(self): else: d1 = abs(y - prevy) d2 = abs(y - nexty) - s = d1 + d2 + s = d1 + d2 + 1e-5 w1 = d1 / s w2 = d2 / s w = max(w1, w2)*2 - 1 # from 0 to 1, because max(w1,w2) is always >= 0.5 @@ -1391,7 +1495,7 @@ def getJsonData(self): def setJsonData(self, value): self.jsonWidget.setFixedHeight(value["height"]) self.jsonWidget.clear() - self.jsonWidget.fromJsonList(value["data"]) + self.jsonWidget.loadFromJsonList(value["data"]) self.jsonWidget.setReadOnly(value["readonly"]) TemplateWidgets = { @@ -1409,11 +1513,39 @@ def setJsonData(self, value): "text": TextTemplateWidget, "vector": VectorTemplateWidget} +def curve_evaluate(data, param): + return evaluateBezierCurve(data["cvs"], param) + +def curve_evaluateFromX(data, param): + return evaluateBezierCurveFromX(data["cvs"], param) + +def listBox_setSelected(data, indices): + data["selected"] = indices + +def listBox_selected(data): + return [data["items"][idx] for idx in data["selected"] if idx < len(data["items"])] + +def comboBox_items(data): + return data["items"] + +def comboBox_setItems(data, items): + data["items"] = items + WidgetsAPI = { - "evaluateBezierCurve": evaluateBezierCurve, - "evaluateBezierCurveFromX": evaluateBezierCurveFromX, "listLerp": listLerp, "clamp": clamp, "smartConversion": smartConversion, "fromSmartConversion": fromSmartConversion, + + # data based + "curve_evaluate": curve_evaluate, + "curve_evaluateFromX": curve_evaluateFromX, + "listBox_selected": listBox_selected, + "listBox_setSelected": listBox_setSelected, + "comboBox_items": comboBox_items, + "comboBox_setItems": comboBox_setItems, + + # obsolete + "evaluateBezierCurve": evaluateBezierCurve, + "evaluateBezierCurveFromX": evaluateBezierCurveFromX, } \ No newline at end of file