diff --git a/__init__.py b/__init__.py index 6f23a93..fab1dab 100644 --- a/__init__.py +++ b/__init__.py @@ -33,28 +33,20 @@ def sendToServer(module): ''' module.sendToServer() return True - + def updateFilesFromServer(): + ''' + Update files from server with SVN, Git, Perforce or other VCS. + ''' + def update(): - ''' - Update files from server with SVN, Git, Perforce or other VCS. - ''' pass - + global updateFilesThread if not updateFilesThread or not updateFilesThread.isRunning(): updateFilesThread = MyThread(update) updateFilesThread.start() -def widgetOnChange(widget, module, attr): - data = widget.getJsonData() - attr.data = data - - if attr.connect: - srcAttr = module.findConnectionSourceForAttribute(attr) - if srcAttr: - srcAttr.updateFromAttribute(attr) - class MyThread(QThread): def __init__(self, runFunction): super().__init__() @@ -63,54 +55,66 @@ def __init__(self, runFunction): def run(self): self.runFunction() -class TabAttributesWidget(QWidget): - needUpdateUI = Signal() +class EditJsonDialog(QDialog): + saved = Signal(dict) + + def __init__(self, data, *, title="Edit"): + super().__init__(parent=QApplication.activeWindow()) + + self.setWindowTitle(title) + self.setGeometry(0, 0, 600, 400) - def __init__(self, module, attributes, *, mainWindow=None, **kwargs): - super(TabAttributesWidget, self).__init__(**kwargs) + layout = QVBoxLayout() + self.setLayout(layout) + + self.jsonWidget = widgets.JsonWidget(data) + + okBtn = QPushButton("OK") + okBtn.clicked.connect(self.saveAndClose) + + layout.addWidget(self.jsonWidget) + layout.addWidget(okBtn) + centerWindow(self) + + def saveAndClose(self): + dataList = self.jsonWidget.toJsonList() + if dataList: + self.saved.emit(dataList[0]) # keep the first item only + self.accept() + +class AttributesWidget(QWidget): + def __init__(self, moduleItem, attributes, *, mainWindow=None, **kwargs): + super().__init__(**kwargs) self.mainWindow = mainWindow - self.module = module + self.moduleItem = moduleItem + + self._attributeAndWidgets = [] # [attribute, nameWidget, templateWidget] layout = QGridLayout() layout.setDefaultPositioning(2, Qt.Horizontal) layout.setColumnStretch(1, 1) self.setLayout(layout) - if self.module: - with captureOutput(self.mainWindow.logWidget): - try: - for attr in self.module.getAttributes(): - self.module.resolveConnection(attr) - except Exception as err: - print("Error: " + str(err)) - self.mainWindow.showLog() - self.mainWindow.logWidget.ensureCursorVisible() - globEnv = self.mainWindow.getModuleGlobalEnv() - globEnv.update({"module": ModuleWrapper(self.module), "ch": self.module.ch, "chset": self.module.chset}) + globEnv.update({"module": ModuleWrapper(self.moduleItem.module), "ch": self.moduleItem.module.ch, "chset": self.moduleItem.module.chset}) for a in attributes: templateWidget = widgets.TemplateWidgets[a.template](env=globEnv) - with captureOutput(self.mainWindow.logWidget): - try: - templateWidget.setJsonData(a.data) - except: - print("Error: invalid JSON data for attribute '{}'".format(a.name)) - a.data = templateWidget.getDefaultData() - self.mainWindow.showLog() - self.mainWindow.logWidget.ensureCursorVisible() - - templateWidget.somethingChanged.connect(lambda w=templateWidget, e=module, a=a: widgetOnChange(w, e, a)) - templateWidget.needUpdateUI.connect(self.needUpdateUI.emit) - self.setWidgetStyle(templateWidget, a) - nameWidget = QLabel(a.name) + + self._attributeAndWidgets.append((a, nameWidget, templateWidget)) + idx = len(self._attributeAndWidgets) - 1 # index of widgets + + self.updateWidget(idx) + 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; }") - - nameWidget.contextMenuEvent = lambda event, a=a, w=templateWidget: self.nameContextMenuEvent(event, a, w) - nameWidget.attribute = a + nameWidget.contextMenuEvent = lambda event, idx=idx: self.nameContextMenuEvent(event, idx) layout.addWidget(nameWidget) layout.addWidget(templateWidget) @@ -118,153 +122,195 @@ def __init__(self, module, attributes, *, mainWindow=None, **kwargs): layout.addWidget(QLabel()) layout.setRowStretch(layout.rowCount(), 1) - def connectionMenu(self, menu, module, attr, widget, path="/"): + def connectionMenu(self, menu, module, attrWidgetIndex, path="/"): + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + subMenu = QMenu(module.name) for a in module.getAttributes(): if a.template == attr.template: - subMenu.addAction(a.name, Callback(self.connectAttr, path+module.name+"/"+a.name, attr, widget)) + subMenu.addAction(a.name, Callback(self.connectAttr, path+module.name+"/"+a.name, attrWidgetIndex)) for ch in module.getChildren(): - self.connectionMenu(subMenu, ch, attr, widget, path+module.name+"/") + self.connectionMenu(subMenu, ch, attrWidgetIndex, path+module.name+"/") if subMenu.actions(): menu.addMenu(subMenu) - def nameContextMenuEvent(self, event, attr, widget): + def nameContextMenuEvent(self, event, attrWidgetIndex): + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + menu = QMenu(self) - if self.module and self.module.parent: + if self.moduleItem and self.moduleItem.parent(): makeConnectionMenu = QMenu("Make connection") - for a in self.module.parent.getAttributes(): + for a in self.moduleItem.module.parent.getAttributes(): if a.template == attr.template: - makeConnectionMenu.addAction(a.name, Callback(self.connectAttr, "/"+a.name, attr, widget)) + makeConnectionMenu.addAction(a.name, Callback(self.connectAttr, "/"+a.name, attrWidgetIndex)) - for ch in self.module.parent.getChildren(): - if ch is self.module: + for ch in self.moduleItem.module.parent.getChildren(): + if ch is self.moduleItem.module: continue - self.connectionMenu(makeConnectionMenu, ch, attr, widget) + self.connectionMenu(makeConnectionMenu, ch, attrWidgetIndex) menu.addMenu(makeConnectionMenu) if attr.connect: - menu.addAction("Break connection", Callback(self.disconnectAttr, attr, widget)) + menu.addAction("Break connection", Callback(self.disconnectAttr, attrWidgetIndex)) + menu.addSeparator() - menu.addAction("Set data", Callback(self.setData, attr, widget)) - menu.addAction("Reset", Callback(self.resetAttr, attr, widget)) + menu.addAction("Edit data", Callback(self.editData, attrWidgetIndex)) + menu.addAction("Edit expression", Callback(self.editExpression, attrWidgetIndex)) menu.addSeparator() - menu.addAction("Expose", Callback(self.exposeAttr, attr, widget)) + menu.addAction("Expose", Callback(self.exposeAttr, attrWidgetIndex)) + menu.addSeparator() + menu.addAction("Reset", Callback(self.resetAttr, attrWidgetIndex)) menu.popup(event.globalPos()) - def setWidgetStyle(self, widget, attr): - tooltip = "" - background = "" + def widgetOnChange(self, attrWidgetIndex): + attr, _, widget = self._attributeAndWidgets[attrWidgetIndex] + + newData = widget.getJsonData() + oldData = copyJson(attr.data) + attr.data = newData + + 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() + + if oldData != newData and not attr.expression: # compare with default value + attr.modified = True + self.updateWidgetStyle(attrWidgetIndex) + + 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() + + def updateWidgets(self): + for i in range(len(self._attributeAndWidgets)): + self.updateWidget(i) + + def updateWidgetStyle(self, attrWidgetIndex): + attr, nameWidget, widget = self._attributeAndWidgets[attrWidgetIndex] + + style = "" + tooltip = [] + if attr.connect: + tooltip.append("Connect: "+attr.connect) + if attr.expression: + tooltip.append("Expression:\n"+attr.expression) + if attr.connect: - tooltip = "Connect: "+attr.connect - background = "#6e6e39" + 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}" + + nameWidget.setText(attr.name+("*" if attr.modified else "")) + + widget.setStyleSheet(style) + widget.setToolTip("\n".join(tooltip)) - widget.setToolTip(tooltip) - widget.setStyleSheet("background-color:"+background) + def updateWidgetStyles(self): + for i in range(len(self._attributeAndWidgets)): + self.updateWidgetStyle(i) - def exposeAttr(self, attr, widget): - if not self.module.parent: + def exposeAttr(self, attrWidgetIndex): + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + + if not self.moduleItem.module.parent: QMessageBox.warning(self, "Rig Builder", "Can't expose attribute to parent: no parent module") return - if self.module.parent.findAttribute(attr.name): + if self.moduleItem.module.parent.findAttribute(attr.name): QMessageBox.warning(self, "Rig Builder", "Can't expose attribute to parent: attribute already exists") return doUsePrefix = QMessageBox.question(self, "Rig Builder", "Use prefix for the exposed attribute name?", QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) == QMessageBox.Yes - prefix = self.module.name + "_" if doUsePrefix else "" + prefix = self.moduleItem.module.name + "_" if doUsePrefix else "" expAttr = attr.copy() expAttr.name = prefix + expAttr.name - self.module.parent.addAttribute(expAttr) - self.connectAttr("/"+expAttr.name, attr, widget) - - def setData(self, attr, widget): - text = json.dumps(attr.data, indent=4).replace("'", "\"") - editText = widgets.EditTextDialog(text, title="Set data", parent=mainWindow) - editText.exec_() - if editText.result(): - with captureOutput(self.mainWindow.logWidget): - try: - data = json.loads(editText.outputText) - tmp = widgets.TemplateWidgets[attr.template]() # also we need check for widget compatibility - tmp.setJsonData(data) - - except: - print("Error: invalid or incompatible JSON data") - self.mainWindow.showLog() - self.mainWindow.logWidget.ensureCursorVisible() - - else: - attr.data = data - widget.setJsonData(data) - - def resetAttr(self, attr, widget): - tmp = widgets.TemplateWidgets[attr.template]() - attr.data = tmp.getDefaultData() - attr.connect = "" - widget.setJsonData(attr.data) - self.setWidgetStyle(widget, attr) + self.moduleItem.module.parent.addAttribute(expAttr) + self.connectAttr("/"+expAttr.name, attrWidgetIndex) - def disconnectAttr(self, attr, widget): - attr.connect = "" - self.setWidgetStyle(widget, attr) - - def connectAttr(self, connect, destAttr, widget): - destAttr.connect = connect - self.module.resolveConnection(destAttr) - widget.setJsonData(destAttr.data) - self.setWidgetStyle(widget, destAttr) - -class SearchReplaceDialog(QDialog): - onReplace = Signal(str, str, dict) # old, new, options + def editData(self, attrWidgetIndex): + def save(data): + attr.data = data + self.updateWidget(attrWidgetIndex) - def __init__(self, options=[], **kwargs): - super(SearchReplaceDialog, self).__init__(**kwargs) + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + w = EditJsonDialog(attr.data, title="Edit data") + w.saved.connect(save) + w.show() - self.optionsWidgets = {} - - self.setWindowTitle("Search/Replace") - layout = QVBoxLayout() - self.setLayout(layout) + def editExpression(self, attrWidgetIndex): + def save(text): + attr.expression = text + self.updateWidget(attrWidgetIndex) + self.updateWidgetStyle(attrWidgetIndex) - self.searchWidget = QLineEdit("L_") - self.replaceWidget = QLineEdit("R_") + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] - btn = QPushButton("Replace") - btn.clicked.connect(self.replaceClicked) + 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.saved.connect(save) + w.show() - gridLayout = QGridLayout() - gridLayout.addWidget(QLabel("Search"),0,0) - gridLayout.addWidget(self.searchWidget,0,1) - gridLayout.addWidget(QLabel("Replace"),1,0) - gridLayout.addWidget(self.replaceWidget,1,1) - layout.addLayout(gridLayout) + def resetAttr(self, attrWidgetIndex): + attr, _, widget = self._attributeAndWidgets[attrWidgetIndex] - for opt in options: - w = QCheckBox(opt) - self.optionsWidgets[opt] = w - layout.addWidget(w) + tmp = widgets.TemplateWidgets[attr.template]() + attr.data = tmp.getDefaultData() + attr.connect = "" + widget.setJsonData(attr.data) + self.updateWidgetStyle(attrWidgetIndex) - layout.addWidget(btn) + def disconnectAttr(self, attrWidgetIndex): + attr, _, _ = self._attributeAndWidgets[attrWidgetIndex] + attr.connect = "" + self.updateWidgetStyle(attrWidgetIndex) - def replaceClicked(self): - opts = {l:w.isChecked() for l,w in self.optionsWidgets.items()} - self.onReplace.emit(self.searchWidget.text(), self.replaceWidget.text(), opts) - self.accept() + def connectAttr(self, connect, attrWidgetIndex): + attr, _, widget = self._attributeAndWidgets[attrWidgetIndex] + attr.connect = connect + self.moduleItem.module.resolveConnection(attr) + widget.setJsonData(attr.data) + self.updateWidgetStyle(attrWidgetIndex) class AttributesTabWidget(QTabWidget): - def __init__(self, module=None, *, mainWindow=None, **kwargs): - super(AttributesTabWidget, self).__init__(**kwargs) + def __init__(self, moduleItem, *, mainWindow=None, **kwargs): + super().__init__(**kwargs) self.mainWindow = mainWindow - self.module = module + self.moduleItem = moduleItem self.tabsAttributes = {} + self._attributesWidget = None self.searchAndReplaceDialog = SearchReplaceDialog(["In all tabs"]) self.searchAndReplaceDialog.onReplace.connect(self.onReplace) @@ -275,7 +321,7 @@ def __init__(self, module=None, *, mainWindow=None, **kwargs): def contextMenuEvent(self, event): menu = QMenu(self) - if self.module: + if self.moduleItem: menu.addAction("Edit attributes", self.editAttributes) menu.addSeparator() menu.addAction("Replace in values", self.searchAndReplaceDialog.exec_) @@ -283,7 +329,7 @@ def contextMenuEvent(self, event): menu.popup(event.globalPos()) def editAttributes(self): - dialog = EditAttributesDialog(self.module, self.currentIndex(), parent=mainWindow) + dialog = EditAttributesDialog(self.moduleItem, self.currentIndex(), parent=mainWindow) dialog.exec_() self.mainWindow.codeEditorWidget.updateState() @@ -318,24 +364,24 @@ def tabChanged(self, idx): title = self.tabText(idx) scrollArea = self.widget(idx) - w = TabAttributesWidget(self.module, self.tabsAttributes[title], mainWindow=self.mainWindow) - w.needUpdateUI.connect(self.updateTabs) - scrollArea.setWidget(w) + self._attributesWidget = AttributesWidget(self.moduleItem, self.tabsAttributes[title], mainWindow=self.mainWindow) + scrollArea.setWidget(self._attributesWidget) self.setCurrentIndex(idx) def updateTabs(self): oldIndex = self.currentIndex() oldCount = self.count() + self._attributesWidget = None self.tabsAttributes.clear() - if not self.module: + if not self.moduleItem: return self.blockSignals(True) tabTitlesInOrder = [] - for a in self.module.getAttributes(): + for a in self.moduleItem.module.getAttributes(): if a.category not in self.tabsAttributes: self.tabsAttributes[a.category] = [] tabTitlesInOrder.append(a.category) @@ -362,14 +408,18 @@ def updateTabs(self): self.tabChanged(oldIndex) self.blockSignals(False) + def updateWidgetStyles(self): + if self._attributesWidget: + self._attributesWidget.updateWidgetStyles() + class ModuleListDialog(QDialog): + moduleSelected = Signal(str) # file path + def __init__(self, **kwargs): - super(ModuleListDialog, self).__init__(**kwargs) + super().__init__(**kwargs) self.setWindowTitle("Module Selector") - self.selectedFileName = "" - layout = QVBoxLayout() self.setLayout(layout) @@ -419,8 +469,6 @@ def showEvent(self, event): pos = self.mapToParent(self.mapFromGlobal(QCursor.pos())) self.setGeometry(pos.x(), pos.y(), 600, 400) - self.selectedFileName = "" - # update files from server self.loadingLabel.show() updateFilesFromServer() @@ -444,7 +492,7 @@ def browseModuleDirectory(self): def treeItemActivated(self, item, _): if item.childCount() == 0: - self.selectedFileName = item.filePath + self.moduleSelected.emit(item.filePath) self.done(0) def updateSource(self): @@ -507,116 +555,166 @@ def findChildByText(text, parent, column=0): dirItem.addChild(item) dirItem.setExpanded(True if mask else False) -class TreeWidget(QTreeWidget): - def __init__(self, *, mainWindow=None, **kwargs): - super(TreeWidget, self).__init__(**kwargs) +class ModuleItem(QTreeWidgetItem): + def __init__(self, module, **kwargs): + super().__init__(**kwargs) + self.module = module - self.mainWindow = mainWindow - self.dragItems = [] # using in drag & drop + self.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled) - self.moduleListDialog = ModuleListDialog() + def clone(self): + return ModuleItem(self.module.copy()) - self.setHeaderLabels(["Name", "Path", "Source", "UID"]) - self.setSelectionMode(QAbstractItemView.ExtendedSelection) # ExtendedSelection + def data(self, column, role): + if column == 0: # name + if role == Qt.EditRole: + return self.module.name - self.header().setSectionResizeMode(QHeaderView.ResizeToContents) + elif role == Qt.DisplayRole: + return self.module.name + ("*" if self.module.modified else " ") - self.setDragEnabled(True) - self.setDragDropMode(QAbstractItemView.InternalMove) - self.setDropIndicatorShown(True) - self.setAcceptDrops(True) + elif role == Qt.ForegroundRole: + isParentMuted = False + isParentReferenced = False - self.setIndentation(30) + parent = self.parent() + while parent: + isParentMuted = isParentMuted or parent.module.muted + isParentReferenced = isParentReferenced or parent.module.uid + parent = parent.parent() - self.setMouseTracking(True) - self.itemDoubleClicked.connect(self.treeItemDoubleClicked) + color = QColor(200, 200, 200) - def drawRow(self, painter, options, modelIdx): - painter.save() + if isParentReferenced: + color = QColor(140, 140, 180) - rect = self.visualRect(modelIdx) - item = self.itemFromIndex(modelIdx) + if self.module.muted or isParentMuted: + color = QColor(100, 100, 100) - indent = self.indentation() + return color - if rect.width() < 0: - return + elif role == Qt.BackgroundRole: + if not re.match("\\w*", self.module.name): + return QColor(170, 50, 50) - isParentMuted = False - isParentReferenced = False - - parent = item.parent() - while parent: - isParentMuted = isParentMuted or parent.module.muted - isParentReferenced = isParentReferenced or parent.module.uid - parent = parent.parent() - - painter.setPen(QPen(QColor(60, 60, 60), 1, Qt.SolidLine)) - numberBranch = int(rect.x() / indent) - if numberBranch > 1: - for i in range(1, numberBranch): - plusInt = i * indent + 10 - x = rect.x() - plusInt - painter.drawLine(x, rect.y(), x, rect.y() + rect.height()) - - if item.childCount() and rect.x() + rect.width() > rect.x(): - painter.setPen(QPen(QColor(100, 100, 100), 1, Qt.SolidLine)) - painter.fillRect(QRect(rect.x() - 16, rect.y() + 2, 12, 12), QColor(45, 45, 45)) - painter.drawRect(rect.x() - 16, rect.y() + 2, 12, 12) - painter.setPen(QPen(QColor(120, 120, 120), 1, Qt.SolidLine)) - if item.isExpanded(): - painter.drawLine(rect.x() - 7, rect.y() + 8, rect.x() - 13, rect.y() + 8) - else: - painter.drawLine(rect.x() - 10, rect.y() + 5, rect.x() - 10, rect.y() + 12) - painter.drawLine(rect.x() - 7, rect.y() + 8, rect.x() - 13, rect.y() + 8) + itemParent = self.parent() + if itemParent and len([ch for ch in itemParent.module.getChildren() if ch.name == self.module.name]) > 1: + return QColor(170, 50, 50) + + return super().data(column, role) - nameRect = self.visualRect(modelIdx.sibling(modelIdx.row(), 0)) - pathRect = self.visualRect(modelIdx.sibling(modelIdx.row(), 1)) - sourceRect = self.visualRect(modelIdx.sibling(modelIdx.row(), 2)) - uidRect = self.visualRect(modelIdx.sibling(modelIdx.row(), 3)) + elif column == 1: # path + if role == Qt.DisplayRole: + return self.module.getRelativeLoadedPathString().replace("\\", "/") + " " - if not re.match("\\w*", item.module.name): - painter.fillRect(nameRect, QBrush(QColor(170, 50, 50))) + elif role == Qt.EditRole: + return "(not editable)" - itemParent = item.parent() - if itemParent and len([ch for ch in itemParent.module.getChildren() if ch.name == item.module.name]) > 1: - painter.fillRect(nameRect, QBrush(QColor(170, 50, 50))) + elif role == Qt.FontRole: + font = QFont() + font.setItalic(True) + return font - # set selected style - if modelIdx in self.selectedIndexes(): - width = nameRect.width() + pathRect.width() + sourceRect.width() + uidRect.width() - painter.fillRect(rect.x()-1, rect.y(), width, rect.height(), QColor(80, 96, 154, 60)) - painter.setPen(QColor(73, 146, 158, 200)) - painter.drawRect(rect.x()-1, rect.y()+1, width, rect.height()-3) + elif role == Qt.ForegroundRole: + return QColor(125, 125, 125) - painter.setPen(QColor(200, 200, 200)) + elif column == 2: # source + source = "" + if self.module.isLoadedFromLocal(): + source = "local" + elif self.module.isLoadedFromServer(): + source = "server" - if isParentReferenced: - painter.setPen(QColor(140, 140, 180)) + if role == Qt.DisplayRole: + return source + " " - if item.module.muted or isParentMuted: - painter.setPen(QColor(90, 90, 90)) + elif role == Qt.EditRole: + return "(not editable)" - modifiedSuffix = "*" if item.module.modified else "" - painter.drawText(nameRect, Qt.AlignLeft | Qt.AlignVCenter, item.module.name+modifiedSuffix) + elif role == Qt.ForegroundRole: + if source == "local": + return QColor(120, 220, 120) + elif source == "server": + return QColor(120, 120, 120) - painter.setPen(QColor(120, 120, 120)) - painter.drawText(pathRect, Qt.AlignLeft | Qt.AlignVCenter, item.text(1)) + elif column == 3: # uid + if role == Qt.DisplayRole: + return self.module.uid[:8] + elif role == Qt.EditRole: + return "(not editable)" + elif role == Qt.ForegroundRole: + return QColor(125, 125, 170) + else: + return super().data(column, role) + + def setData(self, column, role, value): + if column == 0: + if role == Qt.EditRole: + newName = replaceSpecialChars(value).strip() - if item.module.isLoadedFromLocal(): - painter.setPen(QColor(120, 220, 120)) - painter.drawText(sourceRect, "local") + connections = self._saveConnections(self.module) # rename in connections + self.module.name = newName + self.treeWidget().resizeColumnToContents(column) + self._updateConnections(connections) + else: + return super().setData(column, role, value) + + def clearModifiedFlag(self, *, attrFlag=True, moduleFlag=True, children=True): # clear modified flag on embeded modules + if moduleFlag: + self.module.modified = False + self.emitDataChanged() + + if attrFlag: + for a in self.module.getAttributes(): + a.modified = False + + if children: + for ch in self.module.getChildren(): + if not ch.uid: # embeded module + ch.clearModifiedFlag(attrFlag=True, moduleFlag=True, children=True) + else: # only direct children + ch.clearModifiedFlag(attrFlag=True, moduleFlag=False, children=False) + + def _saveConnections(self, currentModule): + connections = [] + for a in currentModule.getAttributes(): + connections.append({"attr":a, "module": currentModule, "connections":currentModule.listConnections(a)}) + + for ch in currentModule.getChildren(): + connections += self._saveConnections(ch) + return connections + + def _updateConnections(self, connections): + for data in connections: + srcAttr = data["attr"] + module = data["module"] + for m, a in data["connections"]: + a.connect = module.getPath().replace(m.getPath(inclusive=False), "") + "/" + srcAttr.name # update connection path + +class TreeWidget(QTreeWidget): + def __init__(self, *, mainWindow=None, **kwargs): + super().__init__(**kwargs) - elif item.module.isLoadedFromServer(): - painter.setPen(QColor(120, 120, 120)) - painter.drawText(sourceRect, "server") + self.mainWindow = mainWindow + self.dragItems = [] # using in drag & drop - painter.setPen(QColor(120, 120, 120)) - painter.drawText(uidRect, item.module.uid[:8]) - painter.restore() + self.moduleListDialog = ModuleListDialog() + self.moduleListDialog.moduleSelected.connect(self.addModuleFromBrowser) + + self.setHeaderLabels(["Name", "Path", "Source", "UID"]) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) # ExtendedSelection + + self.header().setSectionResizeMode(QHeaderView.ResizeToContents) + + self.setDragEnabled(True) + self.setDragDropMode(QAbstractItemView.InternalMove) + self.setDropIndicatorShown(True) + self.setAcceptDrops(True) + + self.setIndentation(30) def paintEvent(self, event): - super(TreeWidget, self).paintEvent(event) + super().paintEvent(event) label = "Press TAB to load modules" fontMetrics = QFontMetrics(self.font()) @@ -627,7 +725,7 @@ def paintEvent(self, event): painter.drawText(viewport.width() - fontMetrics.width(label)-10, viewport.height()-10, label) def dragEnterEvent(self, event): - QTreeWidget.dragEnterEvent(self, event) + super().dragEnterEvent(event) if event.mimeData().hasUrls(): event.accept() @@ -639,13 +737,13 @@ def dragEnterEvent(self, event): event.ignore() def dragMoveEvent(self, event): - QTreeWidget.dragMoveEvent(self, event) + super().dragMoveEvent(event) if event.mimeData().hasUrls(): event.setDropAction(Qt.CopyAction) def dropEvent(self, event): - QTreeWidget.dropEvent(self, event) + super().dropEvent(event) if event.mimeData().hasUrls(): event.setDropAction(Qt.CopyAction) @@ -680,41 +778,8 @@ def dropEvent(self, event): self.dragItems = [] self.dragParents = [] - def treeItemDoubleClicked(self, item, column): - def _keepConnections(currentModule): - connections = [] - for a in currentModule.getAttributes(): - connections.append({"attr":a, "module": currentModule, "connections":currentModule.listConnections(a)}) - - for ch in currentModule.getChildren(): - connections += _keepConnections(ch) - return connections - - if column == 0: # name - newName, ok = QInputDialog.getText(self, "Rig Builder", "New name", QLineEdit.Normal, item.module.name) - if ok and newName: - newName = replaceSpecialChars(newName).strip() - - # rename in connections - connections = _keepConnections(item.module) - - item.module.name = newName - item.setText(0, item.module.name + " ") - - # update connections - for data in connections: - srcAttr = data["attr"] - module = data["module"] - for m, a in data["connections"]: - a.connect = module.getPath().replace(m.getPath(inclusive=False), "") + "/" + srcAttr.name # update connection path - - item.setExpanded(not item.isExpanded()) # revert expand on double click - def makeItemFromModule(self, module): - item = QTreeWidgetItem([module.name+" ", module.getRelativeLoadedPathString()+" ", " ", module.uid]) - item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled) - item.module = module - item.module.modified = False + item = ModuleItem(module) for ch in module.getChildren(): item.addChild(self.makeItemFromModule(ch)) @@ -723,7 +788,7 @@ def makeItemFromModule(self, module): def contextMenuEvent(self, event): menu = QMenu(self) - for m in self.mainWindow.menuBar.findChildren(QMenu): + for m in self.mainWindow.menu.findChildren(QMenu): if m.title(): for a in m.actions(): menu.addAction(a) @@ -734,7 +799,7 @@ def sendModuleToServer(self): selectedItems = self.selectedItems() if not selectedItems: return - + msg = "\n".join([item.module.name for item in selectedItems]) if QMessageBox.question(self, "Rig Builder", "Send modules to server?\n"+msg, QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) != QMessageBox.Yes: @@ -764,32 +829,24 @@ def importModule(self): if DCC == "maya": sceneDir = os.path.dirname(om.MFileIO.currentFile()) - path, _ = QFileDialog.getOpenFileName(mainWindow, "Import", sceneDir, "*.xml") + filePath, _ = QFileDialog.getOpenFileName(mainWindow, "Import", sceneDir, "*.xml") - if not path: + if not filePath: return Module.updateUidsCache() try: - m = Module.loadFromFile(path) + m = Module.loadFromFile(filePath) m.update() - - item = self.makeItemFromModule(m) - self.addTopLevelItem(item) + self.addTopLevelItem(self.makeItemFromModule(m)) except ET.ParseError: - print("Error '{}': invalid module".format(path)) + print("Error '{}': invalid module".format(filePath)) self.mainWindow.showLog() self.mainWindow.logWidget.ensureCursorVisible() def saveModule(self): - def clearModifiedFlag(module): # clear modified flag on embeded modules - module.modified = False - for ch in module.getChildren(): - if not ch.uid: - clearModifiedFlag(ch) - selectedItems = self.selectedItems() if not selectedItems: return @@ -798,7 +855,7 @@ def clearModifiedFlag(module): # clear modified flag on embeded modules if QMessageBox.question(self, "Rig Builder", "Save modules?\n"+msg, QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) != QMessageBox.Yes: return - + for item in selectedItems: outputPath = item.module.getSavePath() @@ -811,9 +868,9 @@ def clearModifiedFlag(module): # clear modified flag on embeded modules os.makedirs(dirname) item.module.saveToFile(outputPath) - clearModifiedFlag(item.module) - - item.setText(1, item.module.getRelativeLoadedPathString()+" ") # update path string + item.emitDataChanged() # path changed + item.clearModifiedFlag() + self.mainWindow.attributesTabWidget.updateWidgetStyles() def saveAsModule(self): for item in self.selectedItems(): @@ -823,7 +880,9 @@ def saveAsModule(self): if outputPath: item.module.uid = generateUid() item.module.saveToFile(outputPath) - item.setText(1, item.module.getRelativeLoadedPathString()+" ") # update path string + item.emitDataChanged() # path and uid changed + item.clearModifiedFlag() + self.mainWindow.attributesTabWidget.updateWidgetStyles() def embedModule(self): selectedItems = self.selectedItems() @@ -834,13 +893,11 @@ def embedModule(self): if QMessageBox.question(self, "Rig Builder", "Embed modules?\n"+msg, QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) != QMessageBox.Yes: return - + for item in selectedItems: item.module.uid = "" item.module.loadedFrom = "" - - for i in range(1,4): # clear path, source and uid - item.setText(i, "") # update path string + item.emitDataChanged() # path and uid changed def updateModule(self): selectedItems = self.selectedItems() @@ -852,7 +909,7 @@ def updateModule(self): msg = "\n".join([item.module.name for item in selectedItems]) if QMessageBox.question(self, "Rig Builder", "Update modules?\n"+msg, QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) != QMessageBox.Yes: return - + for item in selectedItems: item.module.update() @@ -880,8 +937,7 @@ def updateModule(self): def muteModule(self): for item in self.selectedItems(): item.module.muted = not item.module.muted - - self.repaint() + item.emitDataChanged() def duplicateModule(self): newItems = [] @@ -920,6 +976,19 @@ def removeModule(self): else: self.invisibleRootItem().removeChild(item) + def addModuleFromBrowser(self, modulePath): + m = Module.loadFromFile(modulePath) + m.update() + self.addTopLevelItem(self.makeItemFromModule(m)) + + # add to recent modules + if m not in self.mainWindow.infoWidget.recentModules: + self.mainWindow.infoWidget.recentModules.insert(0, m) + if len(self.mainWindow.infoWidget.recentModules) > 10: + self.mainWindow.infoWidget.recentModules.pop() + + return m + def browseModuleSelector(self, *, mask=None, updateSource=None, modulesFrom=None): if mask: self.moduleListDialog.maskWidget.setText(mask) @@ -932,19 +1001,6 @@ def browseModuleSelector(self, *, mask=None, updateSource=None, modulesFrom=None self.moduleListDialog.exec_() - if self.moduleListDialog.selectedFileName: - m = Module.loadFromFile(self.moduleListDialog.selectedFileName) - m.update() - self.addTopLevelItem(self.makeItemFromModule(m)) - - # add to recent modules - if m not in self.mainWindow.infoWidget.recentModules: - self.mainWindow.infoWidget.recentModules.insert(0, m) - if len(self.mainWindow.infoWidget.recentModules) > 10: - self.mainWindow.infoWidget.recentModules.pop() - - return m - def event(self, event): if event.type() == QEvent.KeyPress: if event.key() == Qt.Key_Tab: @@ -956,10 +1012,10 @@ def event(self, event): return QTreeWidget.event(self, event) class TemplateSelectorDialog(QDialog): - def __init__(self, **kwargs): - super(TemplateSelectorDialog, self).__init__(**kwargs) + selectedTemplate = Signal(str) - self.selectedTemplate = None + def __init__(self, **kwargs): + super().__init__(**kwargs) self.setWindowTitle("Template Selector") self.setGeometry(0, 0, 700, 500) @@ -989,7 +1045,7 @@ def __init__(self, **kwargs): centerWindow(self) def selectTemplate(self, t): - self.selectedTemplate = t + self.selectedTemplate.emit(t) self.done(0) def updateTemplates(self): @@ -1013,10 +1069,11 @@ class EditTemplateWidget(QWidget): nameChanged = Signal(str, str) def __init__(self, name, template, **kwargs): - super(EditTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) self.template = template - self.connectedTo = "" + self.attrConnect = "" + self.attrExpression = "" layout = QHBoxLayout() layout.setContentsMargins(0,0,0,0) @@ -1090,7 +1147,8 @@ def downBtnClicked(self): w = editAttrsWidget.insertCustomWidget(self.template, idx+2) w.templateWidget.setJsonData(self.templateWidget.getJsonData()) w.nameWidget.setText(self.nameWidget.text()) - w.connectedTo = self.connectedTo + w.attrConnect = self.attrConnect + w.attrExpression = self.attrExpression self.deleteLater() def upBtnClicked(self): @@ -1100,16 +1158,17 @@ def upBtnClicked(self): w = editAttrsWidget.insertCustomWidget(self.template, idx-1) w.templateWidget.setJsonData(self.templateWidget.getJsonData()) w.nameWidget.setText(self.nameWidget.text()) - w.connectedTo = self.connectedTo + w.attrConnect = self.attrConnect + w.attrExpression = self.attrExpression self.deleteLater() class EditAttributesWidget(QWidget): nameChanged = Signal(str, str) - def __init__(self, module, category, **kwargs): - super(EditAttributesWidget, self).__init__(**kwargs) + def __init__(self, moduleItem, category, **kwargs): + super().__init__(**kwargs) - self.module = module + self.moduleItem = moduleItem self.category = category layout = QVBoxLayout() @@ -1117,12 +1176,13 @@ def __init__(self, module, category, **kwargs): self.attributesLayout = QVBoxLayout() - for a in self.module.getAttributes(): + for a in self.moduleItem.module.getAttributes(): if a.category == self.category: w = self.insertCustomWidget(a.template) w.nameWidget.setText(a.name) w.templateWidget.setJsonData(a.data) - w.connectedTo = a.connect + w.attrConnect = a.connect + w.attrExpression = a.expression layout.addLayout(self.attributesLayout) layout.addStretch() @@ -1156,9 +1216,8 @@ def pasteAttribute(self): def addTemplateAttribute(self): selector = TemplateSelectorDialog(parent=mainWindow) + selector.selectedTemplate.connect(lambda t: self.insertCustomWidget(t)) selector.exec_() - if selector.selectedTemplate: - self.insertCustomWidget(selector.selectedTemplate) def insertCustomWidget(self, template, row=None): if not widgets.TemplateWidgets.get(template): @@ -1179,11 +1238,11 @@ def resizeNameFields(self): w.nameWidget.setFixedWidth(maxWidth) class EditAttributesTabWidget(QTabWidget): - def __init__(self, module, currentIndex=0, **kwargs): - super(EditAttributesTabWidget, self).__init__(**kwargs) + def __init__(self, moduleItem, currentIndex=0, **kwargs): + super().__init__(**kwargs) - self.module = module - self.tempRunCode = module.runCode + self.moduleItem = moduleItem + self.tempRunCode = moduleItem.module.runCode self.setTabBar(QTabBar()) self.setMovable(True) @@ -1192,7 +1251,7 @@ def __init__(self, module, currentIndex=0, **kwargs): self.tabCloseRequested.connect(self.tabCloseRequest) tabTitlesInOrder = [] - for a in self.module.getAttributes(): + for a in self.moduleItem.module.getAttributes(): if a.category not in tabTitlesInOrder: tabTitlesInOrder.append(a.category) @@ -1205,7 +1264,7 @@ def __init__(self, module, currentIndex=0, **kwargs): self.setCurrentIndex(currentIndex) def addTabCategory(self, category): - w = EditAttributesWidget(self.module, category) + w = EditAttributesWidget(self.moduleItem, category) w.nameChanged.connect(self.nameChangedCallback) scrollArea = QScrollArea() @@ -1223,11 +1282,11 @@ def nameChangedCallback(self, oldName, newName): self.tempRunCode = replacePairs(pairs, self.tempRunCode) # rename in connections - for m, a in self.module.listConnections(self.module.findAttribute(oldName)): - a.connect = self.module.getPath().replace(m.getPath(inclusive=False), "") + "/" + newName # update connection path + for m, a in self.moduleItem.module.listConnections(self.moduleItem.module.findAttribute(oldName)): + a.connect = self.moduleItem.module.getPath().replace(m.getPath(inclusive=False), "") + "/" + newName # update connection path def tabBarMouseDoubleClickEvent(self, event): - super(EditAttributesTabWidget, self).mouseDoubleClickEvent(event) + super().mouseDoubleClickEvent(event) idx = self.currentIndex() newName, ok = QInputDialog.getText(self, "Rig Builder", "New name", QLineEdit.Normal, self.tabText(idx)) @@ -1254,18 +1313,18 @@ def clearTabs(self): self.clear() class EditAttributesDialog(QDialog): - def __init__(self, module, currentIndex=0, **kwargs): - super(EditAttributesDialog, self).__init__(**kwargs) + def __init__(self, moduleItem, currentIndex=0, **kwargs): + super().__init__(**kwargs) - self.module = module + self.moduleItem = moduleItem - self.setWindowTitle("Edit Attributes - " + self.module.name) + self.setWindowTitle("Edit Attributes - " + self.moduleItem.module.name) self.setGeometry(0, 0, 800, 600) layout = QVBoxLayout() self.setLayout(layout) - self.tabWidget = EditAttributesTabWidget(self.module, currentIndex) + self.tabWidget = EditAttributesTabWidget(self.moduleItem, currentIndex) okBtn = QPushButton("Ok") okBtn.clicked.connect(self.saveAttributes) @@ -1282,7 +1341,7 @@ def __init__(self, module, currentIndex=0, **kwargs): centerWindow(self) def saveAttributes(self): - self.module.clearAttributes() + self.moduleItem.module.clearAttributes() for i in range(self.tabWidget.count()): attrsLayout = self.tabWidget.widget(i).widget().attributesLayout # tab/scrollArea/EditAttributesWidget @@ -1294,57 +1353,59 @@ def saveAttributes(self): a.data = w.templateWidget.getJsonData() a.template = w.template a.category = self.tabWidget.tabText(i) - a.connect = w.connectedTo - self.module.addAttribute(a) + a.connect = w.attrConnect + a.expression = w.attrExpression + self.moduleItem.module.addAttribute(a) - self.module.runCode = self.tabWidget.tempRunCode - self.module.modified = True + self.moduleItem.module.runCode = self.tabWidget.tempRunCode + self.moduleItem.module.modified = True + self.moduleItem.emitDataChanged() self.accept() class CodeEditorWidget(CodeEditorWithNumbersWidget): - def __init__(self, module=None, *, mainWindow=None, **kwargs): - super(CodeEditorWidget, self).__init__(**kwargs) + def __init__(self, moduleItem=None, *, mainWindow=None, **kwargs): + super().__init__(**kwargs) self.mainWindow = mainWindow - self.module = module + self.moduleItem = moduleItem self._skipSaving = False - self.editorWidget.syntax = PythonHighlighter(self.editorWidget.document()) self.editorWidget.textChanged.connect(self.codeChanged) self.updateState() def codeChanged(self): - if not self.module or self._skipSaving: + if not self.moduleItem or self._skipSaving: return - self.module.runCode = self.editorWidget.toPlainText() - self.module.modified = True + self.moduleItem.module.runCode = self.editorWidget.toPlainText() + self.moduleItem.module.modified = True + self.moduleItem.emitDataChanged() def updateState(self): - if not self.module: + if not self.moduleItem: return self.editorWidget.ignoreStates = True self._skipSaving = True - self.editorWidget.setText(self.module.runCode) + self.editorWidget.setText(self.moduleItem.module.runCode) self._skipSaving = False self.editorWidget.ignoreStates = False self.editorWidget.document().clearUndoRedoStacks() self.generateCompletionWords() - self.editorWidget.preset = id(self.module) + self.editorWidget.preset = id(self.moduleItem) self.editorWidget.loadState() def generateCompletionWords(self): - if not self.module: + if not self.moduleItem: return words = list(self.mainWindow.getModuleGlobalEnv().keys()) words.extend(list(widgets.WidgetsAPI.keys())) - for a in self.module.getAttributes(): + for a in self.moduleItem.module.getAttributes(): words.append("@" + a.name) words.append("@set_" + a.name) @@ -1352,7 +1413,7 @@ def generateCompletionWords(self): class LogHighligher(QSyntaxHighlighter): def __init__(self, parent): - super(LogHighligher, self).__init__(parent) + super().__init__(parent) self.highlightingRules = [] @@ -1384,7 +1445,7 @@ def highlightBlock(self, text): class LogWidget(QTextEdit): def __init__(self, **kwargs): - super(LogWidget, self).__init__(**kwargs) + super().__init__(**kwargs) self.syntax = LogHighligher(self.document()) self.setPlaceholderText("Output and errors or warnings...") @@ -1396,7 +1457,7 @@ def write(self, txt): class WideSplitterHandle(QSplitterHandle): def __init__(self, orientation, parent, **kwargs): - super(WideSplitterHandle, self).__init__(orientation, parent, **kwargs) + super().__init__(orientation, parent, **kwargs) def paintEvent(self, event): painter = QPainter(self) @@ -1407,15 +1468,15 @@ def paintEvent(self, event): class WideSplitter(QSplitter): def __init__(self, orientation, **kwargs): - super(WideSplitter, self).__init__(orientation, **kwargs) + super().__init__(orientation, **kwargs) self.setHandleWidth(16) def createHandle(self): return WideSplitterHandle(self.orientation(), self) -class MyProgressBar(QWidget): +class MyProgressBar(QWidget): def __init__(self, **kwargs): - super(MyProgressBar, self).__init__(**kwargs) + super().__init__(**kwargs) self.queue = [] self.labelSize = 25 @@ -1466,7 +1527,7 @@ def endProgress(self): class RigBuilderWindow(QFrame): def __init__(self): - super(RigBuilderWindow, self).__init__(parent=ParentWindow) + super().__init__(parent=ParentWindow) self.setWindowTitle("Rig Builder") self.setGeometry(0, 0, 1300, 700) @@ -1479,7 +1540,7 @@ def __init__(self): self.logWidget = LogWidget() self.logWidget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - self.attributesTabWidget = AttributesTabWidget(mainWindow=self) + self.attributesTabWidget = AttributesTabWidget(None, mainWindow=self) self.attributesTabWidget.hide() self.treeWidget = TreeWidget(mainWindow=self) @@ -1523,27 +1584,29 @@ def __init__(self): self.progressBarWidget = MyProgressBar() self.progressBarWidget.hide() - self.menuBar = self.createMenu() - layout.setMenuBar(self.menuBar) + self.menu = self.getMenu() + self.treeWidget.addActions(getActions(self.menu)) + setActionsLocalShortcut(self.treeWidget) layout.addWidget(self.vsplitter) layout.addWidget(self.progressBarWidget) centerWindow(self) - def createMenu(self): - menuBar = QMenuBar(self) + def getMenu(self): + menu = QMenu(self) - fileMenu = menuBar.addMenu("File") + fileMenu = menu.addMenu("File") fileMenu.addAction("New", self.treeWidget.insertModule, "Insert") fileMenu.addAction("Import", self.treeWidget.importModule, "Ctrl+I") fileMenu.addSeparator() fileMenu.addAction("Save", self.treeWidget.saveModule, "Ctrl+S") fileMenu.addAction("Save as", self.treeWidget.saveAsModule) fileMenu.addSeparator() - fileMenu.addAction("Locate file", self.locateModuleFile) + fileMenu.addAction("Locate file", self.locateModuleFile) + fileMenu.addAction("Copy tool code", self.copyToolCode) - editMenu = menuBar.addMenu("Edit") + editMenu = menu.addMenu("Edit") editMenu.addAction("Duplicate", self.treeWidget.duplicateModule, "Ctrl+D") editMenu.addSeparator() editMenu.addAction("Update", self.treeWidget.updateModule, "Ctrl+U") @@ -1554,10 +1617,17 @@ def createMenu(self): editMenu.addAction("Remove", self.treeWidget.removeModule, "Delete") editMenu.addAction("Clear all", self.clearAllModules) - helpMenu = menuBar.addMenu("Help") + helpMenu = menu.addMenu("Help") helpMenu.addAction("Documentation", self.showDocumenation) - return menuBar + return menu + + def copyToolCode(self): + selectedItems = self.treeWidget.selectedItems() + if selectedItems: + item = selectedItems[0] + code = '''import rigBuilder;rigBuilder.RigBuilderTool(r"{}").show()'''.format(item.module.getRelativePath()) + QApplication.clipboard().setText(code) def clearAllModules(self): if QMessageBox.question(self, "Rig Builder", "Remove all modules?", QMessageBox.Yes and QMessageBox.No, QMessageBox.Yes) == QMessageBox.Yes: @@ -1572,21 +1642,21 @@ def locateModuleFile(self): subprocess.call("explorer /select,\"{}\"".format(os.path.normpath(item.module.loadedFrom))) def treeItemSelectionChanged(self): - selected = self.treeWidget.selectedItems() - en = True if selected else False + selectedItems = self.treeWidget.selectedItems() + en = True if selectedItems else False self.attributesTabWidget.setVisible(en) self.runBtn.setVisible(en) self.infoWidget.setVisible(not en) self.codeEditorWidget.setEnabled(en and not self.isCodeEditorHidden()) - if selected: - item = selected[0] + if selectedItems: + item = selectedItems[0] - self.attributesTabWidget.module = item.module + self.attributesTabWidget.moduleItem = item self.attributesTabWidget.updateTabs() if self.codeEditorWidget.isEnabled(): - self.codeEditorWidget.module = item.module + self.codeEditorWidget.moduleItem = item self.codeEditorWidget.updateState() def infoLinkClicked(self, url): @@ -1638,23 +1708,17 @@ def displayFiles(files, *, local): self.infoWidget.insertHtml("".join(template)) self.infoWidget.moveCursor(QTextCursor.Start) - def getCurrentModule(self): - selected = self.treeWidget.selectedItems() - if not selected: - return - return selected[0].module - def isCodeEditorHidden(self): return self.vsplitter.sizes()[1] == 0 # code section size def codeSplitterMoved(self, sz, n): - currentModule = self.getCurrentModule() + selectedItems = self.treeWidget.selectedItems() if self.isCodeEditorHidden(): self.codeEditorWidget.setEnabled(False) - elif not self.codeEditorWidget.isEnabled() and currentModule: - self.codeEditorWidget.module = currentModule + elif not self.codeEditorWidget.isEnabled() and selectedItems: + self.codeEditorWidget.moduleItem = selectedItems[0] self.codeEditorWidget.updateState() self.codeEditorWidget.setEnabled(True) @@ -1735,25 +1799,36 @@ def getChildrenCount(item): self.progressBarWidget.endProgress() self.attributesTabWidget.updateTabs() -def RigBuilderTool(spec, child=None): # spec can be full path, relative path, uid +def RigBuilderTool(spec, child=None, *, size=None): # spec can be full path, relative path, uid module = Module.loadModule(spec) if not module: print("Cannot load '{}' module".format(spec)) return - if child: - module = module.findChild(child) + if child is not None: + if type(child) == str: + module = module.findChild(child) + + elif type(child) == int: + module = module.getChildren()[child] + if not module: print("Cannot find '{}' child".format(child)) return w = RigBuilderWindow() - w.menuBar.hide() + w.setWindowTitle("Rig Builder Tool - {}".format(module.getPath())) w.treeWidget.addTopLevelItem(w.treeWidget.makeItemFromModule(module)) w.treeWidget.setCurrentItem(w.treeWidget.topLevelItem(0)) - w.setWindowTitle("Rig Builder Tool - {}".format(module.getPath())) - w.attributesTabWidget.adjustSize() - w.resize(w.attributesTabWidget.size() + QSize(50, 100)) + + if size: + if type(size) in [int, float]: + size = [size, size] + w.resize(size[0], size[1]) + else: # auto size + w.attributesTabWidget.adjustSize() + w.resize(w.attributesTabWidget.size() + QSize(50, 100)) + w.codeEditorWidget.hide() w.treeWidget.hide() centerWindow(w) diff --git a/classes.py b/classes.py index eaac602..3bce61b 100644 --- a/classes.py +++ b/classes.py @@ -87,19 +87,23 @@ def categorizeFilesByModTime(files): class ExitModuleException(Exception):pass class AttributeResolverError(Exception):pass +class AttributeExpressionError(Exception):pass class ModuleNotFoundError(Exception):pass class CopyJsonError(Exception):pass class Attribute(object): - def __init__(self, name, data={}, category="", template="", connect=""): + def __init__(self, name, data={}, category="", template="", connect="", expression=""): self.name = name self.data = copyJson(data) # as json self.category = category self.template = template self.connect = connect # attribute connection, format: /a/b/c, where c is attr, a/b is a parent relative path + self.expression = expression # python code + + self.modified = False # used by UI def copy(self): - return Attribute(self.name, copyJson(self.data), self.category, self.template, self.connect) + return Attribute(self.name, copyJson(self.data), self.category, self.template, self.connect, self.expression) def __eq__(self, other): if not isinstance(other, Attribute): @@ -121,6 +125,18 @@ 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(): @@ -136,17 +152,24 @@ def toXml(self, *, keepConnection=True): attrsStr = " ".join(["{}=\"{}\"".format(k, v) for k, v in attrs]) - header = "" - return header.format(attribs=attrsStr, data=json.dumps(self.data)) + 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 + data["_expression"] = self.expression + header = "" + return header.format(attribs=attrsStr, data=json.dumps(data)) + @staticmethod def fromXml(root): attr = Attribute("") attr.name = root.attrib["name"] attr.template = root.attrib["template"] attr.category = root.attrib["category"] - attr.connect = root.attrib["connect"] + attr.connect = root.attrib["connect"] attr.data = json.loads(root.text) + + # additional data + attr.expression = attr.data.pop("_expression", "") return attr class Module(object): @@ -168,6 +191,8 @@ def __init__(self, name): self.muted = False self.loadedFrom = "" + self.modified = False # used by UI + def copy(self): module = Module(self.name) module.uid = self.uid @@ -450,7 +475,7 @@ def findConnectionSourceForAttribute(self, attr): if srcAttr: return srcModule.findConnectionSourceForAttribute(srcAttr) - return attr + return self, attr def findModuleAndAttributeByPath(self, path): ''' @@ -483,12 +508,14 @@ def resolveConnection(self, attr): if not attr.connect: return - srcAttr = self.findConnectionSourceForAttribute(attr) + srcMod, srcAttr = self.findConnectionSourceForAttribute(attr) + if srcAttr is not attr: if attr.template != srcAttr.template: raise AttributeResolverError("{}: '{}' has incompatible connection template".format(self.name, attr.name)) try: + srcMod.resolveExpression(srcAttr) attr.updateFromAttribute(srcAttr) except TypeError: @@ -497,10 +524,22 @@ def resolveConnection(self, attr): else: raise AttributeResolverError("{}: cannot resolve connection for '{}' which is '{}'".format(self.name, attr.name, attr.connect)) + def resolveExpression(self, attr): + if not attr.expression: + return + + env = {"module": ModuleWrapper(self), "ch": self.ch, "data": attr.data, "value": copyJson(attr.getDefaultValue())} + try: + exec(attr.expression, env) + except Exception as e: + raise AttributeExpressionError("{}: '{}' has invalid expression: {}".format(self.name, attr.name, str(e))) + else: + attr.setDefaultValue(env["value"]) + def ch(self, path, key=None): mod, attr = self.findModuleAndAttributeByPath(path) if attr: - attr = mod.findConnectionSourceForAttribute(attr) + _, attr = mod.findConnectionSourceForAttribute(attr) if not key: return copyJson(attr.getDefaultValue()) else: @@ -511,7 +550,7 @@ def ch(self, path, key=None): def chset(self, path, value, key=None): mod, attr = self.findModuleAndAttributeByPath(path) if attr: - attr = mod.findConnectionSourceForAttribute(attr) + _, attr = mod.findConnectionSourceForAttribute(attr) if not key: attr.setDefaultValue(value) else: @@ -537,7 +576,8 @@ def run(self, env, *, uiCallback=None): attrPrefix = "attr_" for attr in self._attributes: - self.resolveConnection(attr) + self.resolveExpression(attr) + self.resolveConnection(attr) # connection rewrites data attrWrapper = AttributeWrapper(self, attr) localEnv[attrPrefix+attr.name] = attrWrapper.get() @@ -605,7 +645,7 @@ def set(self, v): srcAttr = self._attr if self._attr.connect and self._module.parent: - srcAttr = self._module.findConnectionSourceForAttribute(self._attr) + _, srcAttr = self._module.findConnectionSourceForAttribute(self._attr) default = srcAttr.data.get("default") if default: diff --git a/editor.py b/editor.py index 88ba0ab..4befd64 100644 --- a/editor.py +++ b/editor.py @@ -3,11 +3,11 @@ from PySide2.QtWidgets import * import re -from .utils import clamp +from .utils import clamp, getActions, setActionsLocalShortcut, wordAtCursor, findBracketSpans class PythonHighlighter(QSyntaxHighlighter): def __init__(self, parent): - super(PythonHighlighter, self).__init__(parent) + super().__init__(parent) self.highlightingRules = [] @@ -127,6 +127,8 @@ def match_multiline(self, text, delimiter, in_state, style): else: return False + + def fontSize(font): return font.pixelSize() if font.pixelSize() > 0 else font.pointSize() @@ -159,7 +161,7 @@ def highlightLine(widget, line=None, *, clear=False): class SwoopHighligher(QSyntaxHighlighter): def __init__(self, parent): - super(SwoopHighligher, self).__init__(parent) + super().__init__(parent) self.highlightingRules = [] @@ -200,18 +202,12 @@ def highlightBlock(self, text): self.setCurrentBlockState(0) class SwoopSearchDialog(QDialog): - def __init__(self, edit, **kwargs): - super(SwoopSearchDialog, self).__init__(**kwargs) - - self.edit = edit + def __init__(self, textWidget, **kwargs): + super().__init__(**kwargs) - self.useWordBoundary = False - self.findInsideBrackets = False - self.caseSensitive = True - self.downOnly = False + self.textWidget = textWidget self.replaceMode = False - self.replacePattern = None self.previousLines = [] @@ -224,10 +220,28 @@ def __init__(self, edit, **kwargs): self.setLayout(layout) self.filterWidget = QLineEdit() - self.filterWidget.setToolTip("Ctrl-C - case sensitive
Ctrl-W - word boundary
Ctrl-B - find inside brackets
Ctrl-D - down only
Ctrl-R - replace mode") self.filterWidget.textChanged.connect(self.filterTextChanged) self.filterWidget.keyPressEvent = self.filterKeyPressEvent + self.replaceModeBtn = QPushButton("Replace") + self.replaceModeBtn.clicked.connect(lambda _:self.switchReplaceMode()) + + filterLayout = QHBoxLayout() + filterLayout.addWidget(self.filterWidget) + filterLayout.addWidget(self.replaceModeBtn) + + settingsLayout = QHBoxLayout() + self.caseSensitiveWidget = QCheckBox("Case sensitive") + self.caseSensitiveWidget.setChecked(True) + self.caseSensitiveWidget.stateChanged.connect(self.filterTextChanged) + self.wholeWordWidget = QCheckBox("Whole word") + self.wholeWordWidget.stateChanged.connect(self.filterTextChanged) + self.indentDeeperWidget = QCheckBox("Indent deeper") + self.indentDeeperWidget.stateChanged.connect(self.filterTextChanged) + settingsLayout.addWidget(self.caseSensitiveWidget) + settingsLayout.addWidget(self.wholeWordWidget) + settingsLayout.addWidget(self.indentDeeperWidget) + self.resultsWidget = QTextEdit() self.resultsWidget.setReadOnly(True) self.resultsWidget.setWordWrapMode(QTextOption.NoWrap) @@ -235,41 +249,30 @@ def __init__(self, edit, **kwargs): self.resultsWidget.mousePressEvent = self.resultsMousePressEvent self.resultsWidget.keyPressEvent = self.filterWidget.keyPressEvent - self.statusWidget = QLabel() - self.statusWidget.hide() - - layout.addWidget(self.filterWidget) + layout.addLayout(filterLayout) + layout.addLayout(settingsLayout) layout.addWidget(self.resultsWidget) - layout.addWidget(self.statusWidget) self.rejected.connect(self.whenRejected) - # save initial state - self.text = self.edit.toPlainText() - cursor = self.edit.textCursor() - - self.updateSavedCursor() + def showEvent(self, event): + cursor = self.textWidget.textCursor() + text = self.textWidget.toPlainText() - self.savedSettings["lines"] = self.text.split("\n") + self.savedSettings["cursor"] = cursor + self.savedSettings["scroll"] = self.textWidget.verticalScrollBar().value() + self.savedSettings["lines"] = text.split("\n") findText = cursor.selectedText() if not findText: findText = wordAtCursor(cursor)[0] - self.filterWidget.setText(findText) - - def updateSavedCursor(self): - cursor = self.edit.textCursor() - brackets = findBracketSpans(self.text, cursor.position()) - self.savedSettings["cursor"] = cursor - self.savedSettings["scroll"] = self.edit.verticalScrollBar().value() - self.savedSettings["brackets"] = brackets - - self.findInsideBrackets = brackets[0]!=brackets[1] and self.findInsideBrackets + if findText == self.filterWidget.text(): + self.filterTextChanged() + else: + self.filterWidget.setText(findText) - def showEvent(self, event): - self.updateSavedCursor() + self.switchReplaceMode(False) self.reposition() - self.updateStatus() def resultsMousePressEvent(self, event): cursor = self.resultsWidget.cursorForPosition(event.pos()) @@ -278,73 +281,66 @@ def resultsMousePressEvent(self, event): self.resultsLineChanged() def reposition(self): - c = self.edit.mapToGlobal(self.edit.cursorRect().topLeft()) + c = self.textWidget.mapToGlobal(self.textWidget.cursorRect().topLeft()) w = self.resultsWidget.document().idealWidth() + 30 h = self.resultsWidget.document().blockCount()*self.resultsWidget.cursorRect().height() + 110 - self.setGeometry(c.x(), c.y() + fontSize(self.edit.font())+5, clamp(w, 0, 500), clamp(h, 0, 400)) + self.setGeometry(c.x(), c.y() + fontSize(self.textWidget.font())+5, clamp(w, 0, 500), clamp(h, 0, 400)) + + def switchReplaceMode(self, value=None): + self.replaceMode = not self.replaceMode if value is None else value + + if self.replaceMode: + self.filterWidget.setStyleSheet("background-color: #433567") + self.replacePattern = self.getFilterPattern() + self.replaceModeBtn.setText("Cancel") + else: + self.filterWidget.setStyleSheet("") + self.replaceModeBtn.setText("Replace") + self.filterTextChanged() + + self.caseSensitiveWidget.setEnabled(not self.replaceMode) + self.wholeWordWidget.setEnabled(not self.replaceMode) + self.indentDeeperWidget.setEnabled(not self.replaceMode) def resultsLineChanged(self): if self.replaceMode: return + + caseSensitive = self.caseSensitiveWidget.isChecked() resultsLine = self.resultsWidget.textCursor().block().text() if not resultsLine: return lineNumber = re.search("^(\\d+)", resultsLine).group() - self.edit.gotoLine(int(lineNumber)) + self.textWidget.gotoLine(int(lineNumber)) currentFilter = self.getFilterPattern() + cursor = self.textWidget.textCursor() + currentLine = cursor.block().text() - currentLine = self.edit.textCursor().block().text() - - r = re.search(currentFilter, currentLine, re.IGNORECASE if not self.caseSensitive else 0) + r = re.search(currentFilter, currentLine, re.IGNORECASE if not caseSensitive else 0) if r: - cursor = self.edit.textCursor() pos = cursor.block().position() + r.start() if pos > 0: cursor.setPosition(pos) - self.edit.setTextCursor(cursor) + self.textWidget.setTextCursor(cursor) - cursorY = self.edit.cursorRect().top() - scrollBar = self.edit.verticalScrollBar() - scrollBar.setValue(scrollBar.value() + cursorY - self.edit.geometry().height()/2) + cursorY = self.textWidget.cursorRect().top() + scrollBar = self.textWidget.verticalScrollBar() + scrollBar.setValue(scrollBar.value() + cursorY - self.textWidget.geometry().height()/2) self.reposition() - def updateStatus(self): - items = [] - - if self.useWordBoundary: - items.append("[word]") - - if self.caseSensitive: - items.append("[case]") - - if self.findInsideBrackets: - items.append("[brackets]") - - if self.downOnly: - items.append("[down]") - - if self.replaceMode: - items.append("[REPLACE '%s']"%self.replacePattern) - - if items: - self.statusWidget.setText(" ".join(items)) - self.statusWidget.show() - else: - self.statusWidget.hide() - def filterKeyPressEvent(self, event): ctrl = event.modifiers() & Qt.ControlModifier - rw = self.resultsWidget - line = rw.textCursor().block().blockNumber() - lineCount = rw.document().blockCount()-1 - if event.key() in [Qt.Key_Down, Qt.Key_Up, Qt.Key_PageDown, Qt.Key_PageUp]: + rw = self.resultsWidget + line = rw.textCursor().block().blockNumber() + lineCount = rw.document().blockCount()-1 + highlightLine(rw, clamp(line, 0, lineCount), clear=True) if event.key() == Qt.Key_Down: highlightLine(rw, clamp(line+1, 0, lineCount)) @@ -359,57 +355,14 @@ def filterKeyPressEvent(self, event): self.resultsLineChanged() - elif ctrl and event.key() == Qt.Key_W: # use word boundary - if not self.replaceMode: - self.useWordBoundary = not self.useWordBoundary - self.updateStatus() - self.filterTextChanged() - - elif ctrl and event.key() == Qt.Key_B: # find inside brackets - if not self.replaceMode: - self.findInsideBrackets = not self.findInsideBrackets - self.updateSavedCursor() - self.updateStatus() - self.filterTextChanged() - - elif ctrl and event.key() == Qt.Key_D: # down only - if not self.replaceMode: - self.downOnly = not self.downOnly - self.updateSavedCursor() - self.updateStatus() - self.filterTextChanged() - - elif ctrl and event.key() == Qt.Key_C: # case sensitive - if self.filterWidget.selectedText(): - self.filterWidget.copy() - else: - if not self.replaceMode: - self.caseSensitive = not self.caseSensitive - self.updateStatus() - self.filterTextChanged() - - elif ctrl and event.key() == Qt.Key_R: # replace mode - self.replaceMode = not self.replaceMode - if self.replaceMode: - self.filterWidget.setStyleSheet("background-color: #433567") - self.replacePattern = self.getFilterPattern() - else: - self.filterWidget.setStyleSheet("") - self.filterTextChanged() - - self.updateStatus() - - elif event.key() == Qt.Key_F3: - self.accept() - elif event.key() == Qt.Key_Return: # accept if self.replaceMode: - cursor = self.edit.textCursor() + cursor = self.textWidget.textCursor() savedBlock = self.savedSettings["cursor"].block() savedColumn = self.savedSettings["cursor"].positionInBlock() - doc = self.edit.document() + doc = self.textWidget.document() getIndent = lambda s: s[:len(s) - len(s.lstrip())] @@ -432,26 +385,28 @@ def filterKeyPressEvent(self, event): cursor.endEditBlock() cursor.setPosition(savedBlock.position() + savedColumn) - self.edit.setTextCursor(cursor) - self.edit.verticalScrollBar().setValue(self.savedSettings["scroll"]) + self.textWidget.setTextCursor(cursor) + self.textWidget.verticalScrollBar().setValue(self.savedSettings["scroll"]) - self.edit.setFocus() + self.textWidget.setFocus() self.accept() else: QLineEdit.keyPressEvent(self.filterWidget, event) def whenRejected(self): - self.edit.setTextCursor(self.savedSettings["cursor"]) - self.edit.verticalScrollBar().setValue(self.savedSettings["scroll"]) - self.edit.setFocus() + self.textWidget.setTextCursor(self.savedSettings["cursor"]) + self.textWidget.verticalScrollBar().setValue(self.savedSettings["scroll"]) + self.textWidget.setFocus() def getFilterPattern(self): currentFilter = re.escape(self.filterWidget.text()) + useWordBoundary = self.wholeWordWidget.isChecked() + if not currentFilter: return "" - if self.useWordBoundary: + if useWordBoundary: currentFilter = "\\b" + currentFilter + "\\b" return currentFilter @@ -460,6 +415,11 @@ def filterTextChanged(self): self.resultsWidget.clear() self.resultsWidget.setCurrentCharFormat(QTextCharFormat()) + caseSensitive = self.caseSensitiveWidget.isChecked() + deeperOnly = self.indentDeeperWidget.isChecked() + + getIndent = lambda s: s[:len(s) - len(s.lstrip())] + if self.replaceMode: # replace mode replaceString = self.filterWidget.text() pattern = self.getFilterPattern() @@ -467,8 +427,7 @@ def filterTextChanged(self): lines = [] for line in self.previousLines: n, text = re.search("^(\\d+)\\s*(.*)$", line).groups() - - text = re.sub(self.replacePattern, replaceString, text, 0, re.IGNORECASE if not self.caseSensitive else 0) + text = re.sub(self.replacePattern, replaceString, text, 0, re.IGNORECASE if not caseSensitive else 0) lines.append("{0:<5} {1}".format(n, text.strip())) self.resultsWidget.setText("\n".join(lines)) @@ -476,41 +435,30 @@ def filterTextChanged(self): self.resultsWidget.syntax.rehighlight() else: # search mode - startBlock, endBlock = 0, 0 - - if self.findInsideBrackets: - cursor = QTextCursor(self.savedSettings["cursor"]) - cursor.setPosition(self.savedSettings["brackets"][0]) - startBlock = cursor.block().blockNumber() - cursor.setPosition(self.savedSettings["brackets"][1]) - endBlock = cursor.block().blockNumber() - - if self.downOnly: - cursor = QTextCursor(self.savedSettings["cursor"]) - startBlock = cursor.block().blockNumber() - currentFilter = self.getFilterPattern() - currentBlock = self.edit.textCursor().block().blockNumber() + currentBlockNumber = self.savedSettings["cursor"].block().blockNumber() - self.previousLines = [] + indent = getIndent(self.savedSettings["cursor"].block().text()) counter = 0 currentIndex = 0 + self.previousLines = [] for i, line in enumerate(self.savedSettings["lines"]): if not line.strip(): continue - if self.findInsideBrackets and (i < startBlock or i > endBlock): - continue + if deeperOnly: # works down and indent deeper only + if i < currentBlockNumber: # skip previous lines + continue - if self.downOnly and i < startBlock: - continue + elif getIndent(line) < indent: + break - if i == currentBlock: + if i == currentBlockNumber: currentIndex = counter - r = re.search(currentFilter, line, re.IGNORECASE if not self.caseSensitive else 0) + r = re.search(currentFilter, line, re.IGNORECASE if not caseSensitive else 0) if r: self.previousLines.append("{0:<5} {1}".format(i+1, line.strip())) counter += 1 @@ -523,17 +471,90 @@ def filterTextChanged(self): highlightLine(self.resultsWidget, currentIndex) self.resultsLineChanged() +class CompletionWidget(QTextEdit): + def __init__(self, items, **kwargs): + super().__init__(**kwargs) + + self._prevLine = 0 + + self.setWindowFlags(Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_ShowWithoutActivating) + + self.setReadOnly(True) + self.setWordWrapMode(QTextOption.NoWrap) + + self.updateItems([]) + + def currentLine(self): + return self.textCursor().block().blockNumber() + + def lineCount(self): + return self.document().blockCount() + + def gotoLine(self, line): + line = clamp(line, 0, self.lineCount()-1) + highlightLine(self, self._prevLine, clear=True) + self._prevLine = line + highlightLine(self, self._prevLine) + + def mousePressEvent(self, event): + super().mousePressEvent(event) + self.gotoLine(self.currentLine()) + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Return: + super().keyPressEvent(event) + else: + lineCount = self.lineCount() + + keyMove = {Qt.Key_Down: 1, Qt.Key_Up: -1, Qt.Key_PageDown: 10, Qt.Key_PageUp: -10} + offset = keyMove.get(event.key(), 0) + if offset != 0: + self.gotoLine(clamp(self._prevLine+offset, 0, lineCount)) + + def updateItems(self, items): + if not items: + return + + self.clear() + self.setCurrentCharFormat(QTextCharFormat()) + + lines = [] + for line in items: + lines.append(line) + + self.setText("\n".join(lines)) + + highlightLine(self, 0) + self._prevLine = 0 + + self.autoResize() + + def autoResize(self): + w = self.document().idealWidth() + 10 + h = self.document().blockCount()*self.cursorRect().height() + 30 + + maxWidth = self.parent().width() - self.parent().cursorRect().left() - 30 + maxHeight = self.parent().height() - self.parent().cursorRect().top() - 30 + + self.setFixedSize(clamp(w, 0,maxWidth), clamp(h, 0, maxHeight)) + + def showEvent(self, event): + self.autoResize() + class CodeEditorWidget(QTextEdit): editorState = {} TabSpaces = 4 def __init__(self, **kwargs): - super(CodeEditorWidget, self).__init__(**kwargs) + super().__init__(**kwargs) self.preset = "default" self.commentChar = "#" self.ignoreStates = False # don't save/load states + self.syntax = PythonHighlighter(self.document()) + self._editorState = {} self._canShowCompletions = True @@ -543,6 +564,7 @@ def __init__(self, **kwargs): self._searchStartWord = ("", 0, 0) self._prevCursorPosition = 0 + self.swoopSearchDialog = SwoopSearchDialog(self, parent=self) self.completionWidget = CompletionWidget([], parent=self) self.completionWidget.hide() @@ -552,9 +574,12 @@ def __init__(self, **kwargs): self.setWordWrapMode(QTextOption.NoWrap) self.cursorPositionChanged.connect(self.editorCursorPositionChanged) - self.verticalScrollBar().valueChanged.connect(lambda _: self.saveState(cursor=False, scroll=True, bookmarks=False)) + self.verticalScrollBar().valueChanged.connect(self.scrollBarChanged) self.textChanged.connect(self.editorTextChanged) + self.addActions(getActions(self.getMenu())) + setActionsLocalShortcut(self) + def event(self, event): if event.type() == QEvent.KeyPress: if event.key() == Qt.Key_Tab: @@ -582,7 +607,12 @@ def event(self, event): event.accept() return True - return super(CodeEditorWidget, self).event(event) + return super().event(event) + + def scrollBarChanged(self, _): + self.saveState(cursor=False, scroll=True, bookmarks=False) + if self.completionWidget.isVisible(): + self.completionWidget.hide() def setBookmark(self, line=-1): if line == -1: @@ -604,20 +634,24 @@ def setBookmark(self, line=-1): block.setUserData(blockData) self.saveState(cursor=False, scroll=False, bookmarks=True) - def gotoNextBookmark(self, start=-1): + def gotoNextBookmark(self): doc = self.document() - if start == -1: - start = self.textCursor().block().blockNumber()+1 + def gotoBookmark(startLine): + for i in range(startLine, doc.blockCount()): + b = doc.findBlockByNumber(i) + + blockData = b.userData() + if blockData and blockData.hasBookmark: + self.setTextCursor(QTextCursor(b)) + self.centerLine() + break - for i in range(start, doc.blockCount()): - b = doc.findBlockByNumber(i) + startLine = self.textCursor().block().blockNumber() + gotoBookmark(startLine + 1) - blockData = b.userData() - if blockData and blockData.hasBookmark: - self.setTextCursor(QTextCursor(b)) - self.centerLine() - break + if startLine == self.textCursor().block().blockNumber(): + gotoBookmark(0) def loadState(self, cursor=True, scroll=True, bookmarks=True): if self.ignoreStates: @@ -672,14 +706,16 @@ def saveState(self, cursor=True, scroll=True, bookmarks=False): data = b.userData() if data and data.hasBookmark: state["bookmarks"].append(i) - - def contextMenuEvent(self, event): + + def getMenu(self): menu = QMenu(self) - menu.addAction("Swoop search", self.swoopSearch, "F3") menu.addAction("Highlight selected", self.highlightSelected, "Ctrl+H") menu.addSeparator() menu.addAction("Goto line", self.gotoLine, "Ctrl+G") + menu.addAction("Duplicate line", self.duplicateLine, "Ctrl+D") + menu.addAction("Move line up", lambda: self.moveLine("up"), "Alt+Up") + menu.addAction("Move line down", lambda: self.moveLine("down"), "Alt+Down") menu.addAction("Remove line", self.removeLines, "Ctrl+K") menu.addAction("Comment line", self.toggleCommentBlock, "Ctrl+;") menu.addSeparator() @@ -687,7 +723,10 @@ def contextMenuEvent(self, event): menu.addAction("Next bookmark", self.gotoNextBookmark, "F2") menu.addSeparator() menu.addAction("Select All", self.selectAll, "Ctrl+A") + return menu + def contextMenuEvent(self, event): + menu = self.getMenu() menu.popup(event.globalPos()) def wheelEvent(self, event): @@ -704,7 +743,7 @@ def wheelEvent(self, event): self.parent().numberBarWidget.updateState() else: - QTextEdit.wheelEvent(self, event) + super().wheelEvent(event) def keyPressEvent(self, event): shift = event.modifiers() & Qt.ShiftModifier @@ -713,33 +752,12 @@ def keyPressEvent(self, event): key = event.key() if ctrl and alt and key == Qt.Key_Space: - cursor = self.textCursor() - pos = cursor.position() - start, end = findBracketSpans(self.toPlainText(), pos) - if start is not None and end is not None: - cursor.setPosition(start+1) - cursor.setPosition(end, QTextCursor.KeepAnchor) - self.setTextCursor(cursor) + self.selectInBracket() elif key in [Qt.Key_Left, Qt.Key_Right]: - QTextEdit.keyPressEvent(self, event) + super().keyPressEvent(event) self.completionWidget.hide() - elif alt and key == Qt.Key_F2: # set bookmark - self.setBookmark() - - elif key == Qt.Key_F2: # next bookmark - n = self.textCursor().block().blockNumber() - self.gotoNextBookmark() - if self.textCursor().block().blockNumber() == n: - self.gotoNextBookmark(0) - - elif key == Qt.Key_F3: # emacs swoop - self.swoopSearch() - - elif ctrl and key == Qt.Key_G: # goto line - self.gotoLine() - elif key == Qt.Key_Escape: self.completionWidget.hide() @@ -753,42 +771,14 @@ def keyPressEvent(self, event): block = cursor.block().text() spc = re.search("^(\\s*)", block).groups("")[0] - QTextEdit.keyPressEvent(self, event) + super().keyPressEvent(event) if spc: cursor.insertText(spc) self.setTextCursor(cursor) elif key == Qt.Key_Backtab: - cursor = self.textCursor() - tabSpaces = " "*CodeEditorWidget.TabSpaces - start, end = cursor.selectionStart(), cursor.selectionEnd() - cursor.clearSelection() - - cursor.setPosition(start) - - cursor.beginEditBlock() - while cursor.position() < end: - cursor.movePosition(QTextCursor.StartOfLine) - cursor.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor) - selText = cursor.selectedText() - - # if the text starts with the tab_char, replace it - if selText.startswith(tabSpaces): - text = selText.replace(tabSpaces, "", 1) - end -= len(tabSpaces) - cursor.insertText(text) - - if not cursor.movePosition(QTextCursor.Down): - break - - cursor.endEditBlock() - - elif alt and key == Qt.Key_Up: # move line up - self.moveLineUp() - - elif alt and key == Qt.Key_Down: # move line down - self.moveLineDown() + self.decreaseIndent() elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_PageDown, Qt.Key_PageUp]: if self.completionWidget.isVisible(): @@ -799,50 +789,58 @@ def keyPressEvent(self, event): else: super().keyPressEvent(event) - elif ctrl and key == Qt.Key_H: # highlight selected - self.highlightSelected() + else: + super().keyPressEvent(event) - elif ctrl and key == Qt.Key_K: # remove line - self.removeLines() + def decreaseIndent(self): + cursor = self.textCursor() + tabSpaces = " "*CodeEditorWidget.TabSpaces + start, end = cursor.selectionStart(), cursor.selectionEnd() + cursor.clearSelection() - elif ctrl and key == Qt.Key_Semicolon: # comment - self.toggleCommentBlock() + cursor.setPosition(start) - else: - QTextEdit.keyPressEvent(self, event) + cursor.beginEditBlock() + while cursor.position() < end: + cursor.movePosition(QTextCursor.StartOfLine) + cursor.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor) + selText = cursor.selectedText() + + # if the text starts with the tab_char, replace it + if selText.startswith(tabSpaces): + text = selText.replace(tabSpaces, "", 1) + end -= len(tabSpaces) + cursor.insertText(text) + + if not cursor.movePosition(QTextCursor.Down): + break - def swoopSearch(self): - swoopSearchDialog = SwoopSearchDialog(self, parent=self) - swoopSearchDialog.exec_() + cursor.endEditBlock() - def moveLineUp(self): + def selectInBracket(self): cursor = self.textCursor() - if not cursor.block().previous().isValid() or cursor.selectedText(): - return + pos = cursor.position() + start, end = findBracketSpans(self.toPlainText(), pos) + if start is not None and end is not None: + cursor.setPosition(start+1) + cursor.setPosition(end, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) - text = cursor.block().text() - pos = cursor.positionInBlock() + def swoopSearch(self): + self.swoopSearchDialog.exec_() + def duplicateLine(self): + cursor = self.textCursor() + line = cursor.block().text() + cursor.movePosition(QTextCursor.EndOfBlock) cursor.beginEditBlock() - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - cursor.deletePreviousChar() - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.insertText(text) cursor.insertBlock() + cursor.insertText(line) cursor.endEditBlock() + self.setTextCursor(cursor) - cursor.movePosition(QTextCursor.Up) - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.movePosition(QTextCursor.Right, n=pos) - - self.setTextCursor(cursor) - - def moveLineDown(self): + def moveLine(self, direction): cursor = self.textCursor() - if not cursor.block().next().isValid() or cursor.selectedText(): - return text = cursor.block().text() pos = cursor.positionInBlock() @@ -851,12 +849,21 @@ def moveLineDown(self): cursor.movePosition(QTextCursor.StartOfBlock) cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) cursor.removeSelectedText() - cursor.deleteChar() - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.insertBlock() - cursor.insertText(text) - cursor.endEditBlock() + if direction == "up": + cursor.deletePreviousChar() + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.insertText(text) + cursor.insertBlock() + cursor.movePosition(QTextCursor.Up) + + elif direction == "down": + cursor.deleteChar() + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.insertBlock() + cursor.insertText(text) + + cursor.endEditBlock() cursor.movePosition(QTextCursor.StartOfBlock) cursor.movePosition(QTextCursor.Right, n=pos) @@ -1078,186 +1085,35 @@ def showCompletions(self, items): self.completionWidget.show() -def findOpeningBracketPosition(text, offset, brackets="{(["): - openingBrackets = "{([" - closingBrackets = "})]" - stack = [0 for i in range(len(openingBrackets))] # for each bracket set 0 as default - - if offset < 0 or offset >= len(text): - return None - - if text[offset] in closingBrackets: - offset -= 1 - - for i in range(offset, -1, -1): - c = text[i] - - if c in brackets and c in openingBrackets and stack[openingBrackets.index(c)] == 0: - return i - - elif c in openingBrackets: - stack[openingBrackets.index(c)] += 1 - - elif c in closingBrackets: - stack[closingBrackets.index(c)] -= 1 - -def findClosingBracketPosition(text, offset, brackets="})]"): - openingBrackets = "{([" - closingBrackets = "})]" - stack = [0 for i in range(len(openingBrackets))] # for each bracket set 0 as default - - if offset < 0 or offset >= len(text): - return None - - if text[offset] in openingBrackets: - offset += 1 - - for i in range(offset, len(text)): - c = text[i] - - if c in brackets and c in closingBrackets and stack[closingBrackets.index(c)] == 0: - return i - - elif c in openingBrackets: - stack[openingBrackets.index(c)] += 1 - - elif c in closingBrackets: - stack[closingBrackets.index(c)] -= 1 - -def findBracketSpans(text, offset): - s = findOpeningBracketPosition(text, offset, "{([") - if s is not None: - matchingClosingBracket = {"{":"}", "(":")", "[":"]"}[text[s]] - e = findClosingBracketPosition(text, offset, matchingClosingBracket) - else: - e = findClosingBracketPosition(text, offset, "})]") - return (s,e) - -def wordAtCursor(cursor): - cursor = QTextCursor(cursor) - pos = cursor.position() - - lpart = "" - start = pos-1 - ch = cursor.document().characterAt(start) - while ch and re.match("[@\\w]", ch): - lpart += ch - start -= 1 - - if ch == "@": # @ can be the first character only - break - - ch = cursor.document().characterAt(start) - - rpart = "" - end = pos - ch = cursor.document().characterAt(end) - while ch and re.match("[\\w]", ch): - rpart += ch - end += 1 - ch = cursor.document().characterAt(end) - - return (lpart[::-1]+rpart, start+1, end) - -class CompletionWidget(QTextEdit): - def __init__(self, items, **kwargs): - super(CompletionWidget, self).__init__(**kwargs) - - self._prevLine = 0 - - self.setWindowFlags(Qt.FramelessWindowHint) - self.setAttribute(Qt.WA_ShowWithoutActivating) - - self.setReadOnly(True) - self.setWordWrapMode(QTextOption.NoWrap) - - self.updateItems([]) - - def currentLine(self): - return self.textCursor().block().blockNumber() - - def lineCount(self): - return self.document().blockCount() - - def gotoLine(self, line): - highlightLine(self, self._prevLine, clear=True) - self._prevLine = line - highlightLine(self, self._prevLine) - - def mousePressEvent(self, event): - super().mousePressEvent(event) - self.gotoLine(self.currentLine()) - - def keyPressEvent(self, event): - if event.key() == Qt.Key_Return: - super().keyPressEvent(event) - else: - lineCount = self.lineCount() - - keyMove = {Qt.Key_Down: 1, Qt.Key_Up: -1, Qt.Key_PageDown: 10, Qt.Key_PageUp: -10} - offset = keyMove.get(event.key(), 0) - if offset != 0: - self.gotoLine(clamp(self._prevLine+offset, 0, lineCount)) - - def updateItems(self, items): - if not items: - return - - self.clear() - self.setCurrentCharFormat(QTextCharFormat()) - - lines = [] - for line in items: - lines.append(line) - - self.setText("\n".join(lines)) - - highlightLine(self, 0) - self._prevLine = 0 - - self.autoResize() - - def autoResize(self): - w = self.document().idealWidth() + 10 - h = self.document().blockCount()*self.cursorRect().height() + 30 - - maxWidth = self.parent().width() - self.parent().cursorRect().left() - 30 - maxHeight = self.parent().height() - self.parent().cursorRect().top() - 30 - - self.setFixedSize(clamp(w, 0,maxWidth), clamp(h, 0, maxHeight)) - - def showEvent(self, event): - self.autoResize() - class NumberBarWidget(QWidget): - def __init__(self, edit, *kwargs): - super(NumberBarWidget, self).__init__(*kwargs) - self.edit = edit + def __init__(self, textWidget, *kwargs): + super().__init__(*kwargs) + self.textWidget = textWidget self.highest_line = 0 def updateState(self, *args): - self.setFont(self.edit.font()) + self.setFont(self.textWidget.font()) width = self.fontMetrics().width(str(self.highest_line)) + 19 self.setFixedWidth(width) self.update() def paintEvent(self, event): - contents_y = self.edit.verticalScrollBar().value() - page_bottom = contents_y + self.edit.viewport().height() + contents_y = self.textWidget.verticalScrollBar().value() + page_bottom = contents_y + self.textWidget.viewport().height() font_metrics = self.fontMetrics() - current_block = self.edit.document().findBlock(self.edit.textCursor().position()) + current_block = self.textWidget.document().findBlock(self.textWidget.textCursor().position()) painter = QPainter(self) line_count = 0 # Iterate over all text blocks in the document. - block = self.edit.document().begin() + block = self.textWidget.document().begin() while block.isValid(): line_count += 1 # The top left position of the block in the document - position = self.edit.document().documentLayout().blockBoundingRect(block).topLeft() + position = self.textWidget.document().documentLayout().blockBoundingRect(block).topLeft() # Check if the position of the block is out side of the visible # area. @@ -1273,19 +1129,19 @@ def paintEvent(self, event): block = block.next() - self.highest_line = self.edit.document().blockCount() + self.highest_line = self.textWidget.document().blockCount() painter.end() QWidget.paintEvent(self, event) class TextBlockData(QTextBlockUserData): def __init__(self): - super(TextBlockData, self).__init__() + super().__init__() self.hasBookmark = False class CodeEditorWithNumbersWidget(QWidget): def __init__(self, **kwargs): - super(CodeEditorWithNumbersWidget, self).__init__(**kwargs) + super().__init__(**kwargs) self.editorWidget = CodeEditorWidget() diff --git a/jsonWidget.py b/jsonWidget.py new file mode 100644 index 0000000..7510581 --- /dev/null +++ b/jsonWidget.py @@ -0,0 +1,670 @@ +from PySide2.QtWidgets import * +from PySide2.QtCore import * +from PySide2.QtGui import * +import json +import re +import os + +from .utils import clamp, getActions, centerWindow, setActionsLocalShortcut, SimpleUndo, SearchReplaceDialog + +RootDirectory = os.path.dirname(__file__) + +FloatType = 6 # QMetaType.Double + +class EditJsonTextWindow(QDialog): + saved = Signal(object) + + def __init__(self, data, *, readOnly=False, **kwargs): + super().__init__(**kwargs) + + self.setWindowTitle("Edit JSON") + self.setGeometry(100, 100, 600, 500) + + layout = QVBoxLayout() + layout.setMargin(0) + self.setLayout(layout) + + self.prettyPrintWidget = QCheckBox("Pretty print") + self.prettyPrintWidget.toggled.connect(self.prettyPrintToggled) + + self.textWidget = QTextEdit() + self.textWidget.setPlainText(json.dumps(data)) + self.textWidget.setReadOnly(readOnly) + self.textWidget.setTabStopWidth(16) + self.textWidget.setAcceptRichText(False) + self.textWidget.setWordWrapMode(QTextOption.NoWrap) + + btn = QPushButton("Save" if not readOnly else "Close") + btn.clicked.connect(self.saveAndClose if not readOnly else self.accept) + + layout.addWidget(self.prettyPrintWidget) + layout.addWidget(self.textWidget) + layout.addWidget(btn) + + centerWindow(self) + + self.prettyPrintWidget.setChecked(True) + + def prettyPrintToggled(self, value): + try: + data = json.loads(self.textWidget.toPlainText()) + self.textWidget.setPlainText(json.dumps(data, indent=4 if value else None)) + except: + pass + + def saveAndClose(self): + try: + data = json.loads(self.textWidget.toPlainText()) + self.saved.emit(data) + self.accept() + except: + QMessageBox.critical(self, "DemBones Tool", "Invalid JSON") + +class JsonItem(QTreeWidgetItem): + NoneType = 0 + BoolType = 1 + IntType = 2 + FloatType = 3 + StringType = 4 + ListType = 5 + DictType = 6 + + KeyRole = Qt.UserRole + 1 + + def __init__(self, jsonType, data=None): + super().__init__() + + self._editValue = None + + self.jsonType = jsonType + + self.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + + if jsonType == self.ListType: + self.setFlags(self.flags() | Qt.ItemIsDropEnabled) + + elif jsonType in [self.BoolType, self.IntType, self.FloatType, self.StringType]: + self.setFlags(self.flags() | Qt.ItemIsEditable) + + colors = {self.BoolType: QColor("#CDEB8B"), + self.IntType: QColor("#B85E28"), + self.FloatType: QColor("#BF8208"), + self.StringType: QColor("#AAAA33"), + self.ListType: QColor("#008A00"), + self.DictType: QColor("#28A6E3")} + + self.setForeground(0, QBrush(colors.get(jsonType, Qt.gray))) + + self.setData(0, Qt.EditRole, data) + + def clone(self): + item = JsonItem(self.jsonType) + item._editValue = self._editValue + item.setText(0, self.text(0)) + item.setData(0, self.KeyRole, self.data(0, self.KeyRole)) + for i in range(self.childCount()): + item.addChild(self.child(i).clone()) + return item + + def setData(self, _, role, value): + if role == Qt.EditRole: + self._editValue = value + + super().setData(0, role, value) + + def data(self, _, role): + if role == Qt.ToolTipRole: + return str(self._editValue or "") + + elif role == Qt.EditRole: + return self._editValue + + elif role == Qt.DisplayRole: + key = self.data(0, self.KeyRole) + + if self.jsonType == self.ListType: + key = key or "" + return key + "[%d]"%self.childCount() + + elif self.jsonType == self.DictType: + key = key or "" + + childCount = self.childCount() + maxChildKeys = 10 + children = [] + for i in range(childCount): + if i >= maxChildKeys: + break + k = self.child(i).data(0, self.KeyRole) + children.append(k) + + items = ",".join(children) + suffix = "..." if childCount > maxChildKeys else "" + return key + "{%s%s}"%(items, suffix) + + else: + value = self._editValue + if self.jsonType == self.StringType: + if len(value) > 25: + value = value[:25] + "..." + value = "\"{}\"".format(value) + + if key: + return key + ":" + str(value) + else: + return str(value) + 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: + idx = self.treeWidget().invisibleRootItem().indexOfChild(self) + return path + "[%d]"%idx + + if parent.jsonType == parent.ListType: + idx = parent.indexOfChild(self) + return parent.getPath(path) + "[%d]"%idx + + elif parent.jsonType == parent.DictType: + key = self.data(0, self.KeyRole) + return parent.getPath(path) + "[\"%s\"]"%key + + return path + +class FloatEditor(QDoubleSpinBox): + def __init__(self, parent=None): + super().__init__(parent) + self.setDecimals(6) + +class FloatEditorCreator(QItemEditorCreatorBase): + def __init__(self): + super().__init__() + + def createWidget(self, parent): + return FloatEditor(parent) + +class JsonItemFactor(QItemEditorFactory): + def __init__(self): + super().__init__() + self.registerEditor(FloatType, FloatEditorCreator()) + +class JsonWidget(QTreeWidget): + itemMoved = Signal(QTreeWidgetItem) + itemAdded = Signal(QTreeWidgetItem) + itemRemoved = Signal(QTreeWidgetItem) + dataLoaded = Signal() + cleared = Signal() + readOnlyChanged = Signal(bool) + rootChanged = Signal(QTreeWidgetItem) + + def __init__(self, data=None, **kwargs): + super().__init__(**kwargs) + + self._clipboard = [] + self._readOnly = False + self._undoSystem = SimpleUndo() + + self._searchReplaceDialog = SearchReplaceDialog(["Keys"]) + self._searchReplaceDialog.onReplace.connect(self.onSearchReplace) + + self.header().hide() + self.setDragDropMode(QAbstractItemView.InternalMove) + self.setDragEnabled(True) + self.setDropIndicatorShown(True) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setIndentation(32) + + self.itemDelegate().setItemEditorFactory(JsonItemFactor()) + + self.setReadOnly(self._readOnly) + + if data: + self.fromJsonList([data]) + + def getMenu(self): + menu = QMenu(self) + + fileMenu = menu.addMenu("File") + fileMenu.addAction("Save all", self.saveToFile) + fileMenu.addAction("Export selected", lambda: self.saveToFile(item=self.selectedItem())) + + if not self._readOnly: + fileMenu.addAction("Load", self.loadFromFile) + fileMenu.addAction("Import", self.importFile) + + menu.addSeparator() + menu.addAction("Edit JSON", self.editItemData, "Return") + menu.addAction("Edit key", self.editKey, "Ctrl+Return") + + undoLabel = "Undo" + if not self._undoSystem.isEmpty(): + undoLabel += " '{}'".format(self._undoSystem.getLastOperationName()) + undoAction = menu.addAction(undoLabel, self._undoSystem.undo) + undoAction.setEnabled(not self._undoSystem.isEmpty()) + menu.addSeparator() + + addMenu = menu.addMenu("Add") + addGroup = QActionGroup(self) + addGroup.setExclusive(True) + for l, d, key in [("none", None, ""), ("bool", True, "1"), ("int", 0, "2"), ("float", 0.0, "3"), ("string", "", "4"), ("list", [], "5"), ("dict", {}, "6")]: + action = addGroup.addAction(l) + action.setData(d) + action.setShortcut(key) + + addGroup.triggered.connect(lambda action :self.addItem(action.data(), self.selectedItem())) + addMenu.addActions(addGroup.actions()) + + menu.addAction("Remove", self.removeItem, "Delete") + + def f(): + if QMessageBox.question(self, "Rig Builder", "Remove all items?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) == QMessageBox.Yes: + self.clear() + self.cleared.emit() + self._undoSystem.flush() + menu.addAction("Clear", f) + + menu.addSeparator() + + moveMenu = menu.addMenu("Move") + moveMenu.addAction("Top", lambda:self.moveItem(-9999999), "Ctrl+Shift+Up") + moveMenu.addAction("Up", lambda:self.moveItem(-1), "Shift+Up") + moveMenu.addAction("Down", lambda:self.moveItem(1), "Shift+Down") + moveMenu.addAction("Bottom", lambda:self.moveItem(9999999), "Ctrl+Shift+Down") + + menu.addAction("Duplicate", self.duplicateItem, "Ctrl+D") + + menu.addAction("Copy", self.copyItem, "Ctrl+C") + menu.addAction("Cut", self.cutItem, "Ctrl+X") + menu.addAction("Paste", self.pasteItem, "Ctrl+V") + + menu.addAction("Replace text", self._searchReplaceDialog.exec_, "Ctrl+R") + + else: + menu.addAction("View JSON", self.editItemData, "Return") + + menu.addSeparator() + expandMenu = menu.addMenu("Expand") + expandMenu.addAction("Toggle", lambda:self.expandItem(self.selectedItem(), recursive=False), "Space") + expandMenu.addAction("All", lambda:self.expandItem(self.selectedItem(), True)) + expandMenu.addAction("Collapse all", lambda:self.expandItem(self.selectedItem(), False)) + expandMenu.addAction("Toggle all", lambda:self.expandItem(self.selectedItem()), "Shift+Space") + + menu.addAction("Reveal", self.revealSelected, "F") + menu.addAction("Set as root", lambda: self.setRootItem(self.selectedItem()), "Ctrl+Space") + menu.addAction("Reset root", self.setRootItem, "Escape") + menu.addAction("Copy path", self.copyPath) + + menu.addSeparator() + + readOnlyItem = menu.addAction("Read only", lambda: self.setReadOnly(not self._readOnly)) + readOnlyItem.setCheckable(True) + readOnlyItem.setChecked(self._readOnly) + menu.addSeparator() + + return menu + + def contextMenuEvent(self, event): + menu = self.getMenu() + menu.popup(event.globalPos()) + + def findItemsByType(self, jsonTypes, parent=None, *, recursive=True): + items = [] + parent = parent or self.invisibleRootItem() + for i in range(parent.childCount()): + item = parent.child(i) + if not jsonTypes or item.jsonType in jsonTypes: + items.append(item) + if recursive: + items.extend(self.findItemsByType(jsonTypes, item, recursive=True)) + return items + + def copyPath(self): + item = self.selectedItem() + if item: + QApplication.clipboard().setText(item.getPath()) + + def onSearchReplace(self, old, new, opts): + doReplaceKeys = opts.get("Keys") + + items = self.findItemsByType([], self.selectedItem()) + changedItems = [] + for item in items: + if item.jsonType == item.StringType: + value = item.data(0, Qt.EditRole) + if old in value: + item.setData(0, Qt.EditRole, value.replace(old, new)) + changedItems.append(item) + + if doReplaceKeys: + key = item.data(0, item.KeyRole) + if key and old in key: + item.setData(0, item.KeyRole, key.replace(old, new)) + changedItems.append(item) + + # undo + def f(): + for item in changedItems: + if item.jsonType == item.StringType: + value = item.data(0, Qt.EditRole) + item.setData(0, Qt.EditRole, value.replace(new, old)) + + if doReplaceKeys: + key = item.data(0, item.KeyRole) + item.setData(0, item.KeyRole, key.replace(new, old)) + + self._undoSystem.push("ReplaceText", f) + + def editKey(self): + item = self.selectedItem() + if item: + key = item.data(0, item.KeyRole) + 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)) + + # undo + def f(): + item.setData(0, item.KeyRole, key) + self._undoSystem.push("EditKey", f) + + def isReadOnly(self): + return self._readOnly + + def setReadOnly(self, value): + for a in list(self.actions()): + self.removeAction(a) + + self._readOnly = value + + self.addActions(getActions(self.getMenu())) + setActionsLocalShortcut(self) + + self.readOnlyChanged.emit(value) + + def selectedItem(self): + selectedItems = self.selectedItems() + return selectedItems[-1] if selectedItems else None + + def saveToFile(self, *, path=None, item=None): + if not path: + path, _ = QFileDialog.getSaveFileName(self, "Save JSON", "", "JSON (*.json)") + + if path: + if item: + data = self.itemToJson(item) + else: + data = self.toJsonList() + + with open(path, "w") as f: + json.dump(data, f) + + def loadFromFile(self): + path, _ = QFileDialog.getOpenFileName(self, "Load JSON", "", "JSON (*.json)") + if path: + self.clear() + with open(path, "r") as f: + d = json.load(f) + self.fromJsonList([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]) + + def setRootItem(self, item=None): + if item and item.jsonType in [item.ListType, item.DictType]: + self.setRootIndex(self.indexFromItem(item)) + elif item is None: + self.setRootIndex(QModelIndex()) + else: + return + + self.rootChanged.emit(item) + + def setItemJson(self, item, data): + parentItem = item.parent() + + newItem = self.itemFromJson(data) + newItem.setData(0, newItem.KeyRole, item.data(0, item.KeyRole)) + if parentItem: + if parentItem.jsonType == parentItem.DictType: + newItem.setData(0, Qt.DisplayRole, item.text(0)) + newItem.setFlags(item.flags()) + else: + parentItem = self.invisibleRootItem() + + idx = parentItem.indexOfChild(item) + isSelected = item.isSelected() + isExpanded = item.isExpanded() + parentItem.insertChild(idx, newItem) + parentItem.removeChild(item) + newItem.setExpanded(isExpanded) + newItem.setSelected(isSelected) + return newItem + + def revealSelected(self): + selectedItems = self.selectedItems() + if selectedItems: + 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) + + # 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) + + 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_() + + def moveItem(self, direction): + selectedItems = self.selectedItems() + sortedItems = sorted(selectedItems, key=lambda x: -direction*(x.parent() or self.invisibleRootItem()).indexOfChild(x)) # the lowest first + + # undo + _items = [((item.parent() or self.invisibleRootItem()).indexOfChild(item), item) for item in sortedItems] # save indices + def f(): + for idx, item in _items if direction > 0 else _items[::-1]: + parentItem = item.parent() or self.invisibleRootItem() + parentItem.takeChild(parentItem.indexOfChild(item)) # remove + parentItem.insertChild(idx, item) + self._undoSystem.push("Move", f) + + for item in sortedItems: + parentItem = item.parent() or self.invisibleRootItem() + + expand = item.isExpanded() + idx = parentItem.indexOfChild(item) + parentItem.takeChild(idx) + parentItem.insertChild(clamp(idx+direction, 0, parentItem.childCount()), item) + item.setSelected(True) + item.setExpanded(expand) + + self.itemMoved.emit(item) + + def duplicateItem(self): + selectedItems = self.selectedItems() + + self._undoSystem.beginEditBlock("Duplicate") + + for item in selectedItems: + data = self.itemToJson(item) + parentItem = item.parent() or self.invisibleRootItem() + idx = parentItem.indexOfChild(item) + newItem = self.addItem(data, parentItem, insertIndex=idx+1 if len(selectedItems) == 1 else None) + item.setSelected(False) + + self.itemAdded.emit(newItem) + + self._undoSystem.endEditBlock() + + def copyItem(self): + self._clipboard = [] + for item in self.selectedItems(): + self._clipboard.append(self.itemToJson(item)) + + def cutItem(self): + self.copyItem() + self._undoSystem.beginEditBlock("Cut") + self.removeItem() + self._undoSystem.endEditBlock() + + def pasteItem(self): + parentItem = self.selectedItem() + self._undoSystem.beginEditBlock("Paste") + for json in self._clipboard: + self.addItem(json, parentItem) + self._undoSystem.endEditBlock() + + def addItem(self, json, parentItem=None, *, insertIndex=None): + if not parentItem: + parentItem = self.itemFromIndex(self.rootIndex()) + + item = self.itemFromJson(json) + + if parentItem and parentItem is not self.invisibleRootItem(): + if parentItem.jsonType == parentItem.DictType: + key = parentItem.findUniqueKey() + item.setData(0, item.KeyRole, key) + elif parentItem.jsonType != parentItem.ListType: + return + else: + parentItem = self.invisibleRootItem() + + if insertIndex is None: + parentItem.addChild(item) + else: + parentItem.insertChild(insertIndex, item) + + item.setSelected(True) + self.itemAdded.emit(item) + + # undo + def f(): + parentItem.removeChild(item) + self._undoSystem.push("Add", f) + + return item + + def removeItem(self): + selectedItems = self.selectedItems() + + # add undo + _undoData = [] + for item in selectedItems: + parent = item.parent() or self.invisibleRootItem() + idx = parent.indexOfChild(item) + _undoData.append([self.getPathIndex(parent), item.clone(), idx]) + + def f(): + for parentIdx, item, idx in _undoData: + parent = self.findItemByPathIndex(parentIdx) + parent.insertChild(idx, item) + self._undoSystem.push("Remove", f) + + for item in selectedItems: + (item.parent() or self.invisibleRootItem()).removeChild(item) + self.itemRemoved.emit(item) + + def getPathIndex(self, item): # auxiliary function for undo system + parent = item.parent() + if not parent: + return [self.invisibleRootItem().indexOfChild(item)] + + idx = parent.indexOfChild(item) + return self.getPathIndex(parent) + [idx] + + def findItemByPathIndex(self, pathIndex, parent=None): + parent = parent or self.invisibleRootItem() + if not pathIndex: + return parent + + idx = pathIndex[0] + if idx < parent.childCount(): + return self.findItemByPathIndex(pathIndex[1:], parent.child(idx)) + + def expandItem(self, item, value=None, *, recursive=True): + v = not item.isExpanded() if value is None else value + if item and item is not self.invisibleRootItem(): + if item.jsonType == item.DictType or (item.jsonType == item.ListType and item.childCount() < 10): + item.setExpanded(v) + + if recursive: + for i in range(item.childCount()): + self.expandItem(item.child(i), v, recursive=True) + + def toJsonList(self): + return [self.itemToJson(self.topLevelItem(i)) for i in range(self.topLevelItemCount())] + + def fromJsonList(self, dataList): + for d in dataList: + self.addTopLevelItem(self.itemFromJson(d)) + + self.expandItem(self.invisibleRootItem(), True) + self.dataLoaded.emit() + + self._undoSystem.flush() + + def itemToJson(self, item): + if item.jsonType == item.ListType: + json = [] + for i in range(item.childCount()): + json.append(self.itemToJson(item.child(i))) + return json + + elif item.jsonType == item.DictType: + json = {} + for i in range(item.childCount()): + keyItem = item.child(i) + key = keyItem.data(0, keyItem.KeyRole) + json[key] = self.itemToJson(keyItem) + return json + + else: + return item.data(0, Qt.EditRole) + + def itemFromJson(self, data): + if type(data) == list: + item = JsonItem(JsonItem.ListType) + for k in data: + chItem = self.itemFromJson(k) + chItem.setFlags(chItem.flags() | Qt.ItemIsDragEnabled) + item.addChild(chItem) + + elif type(data) == dict: + item = JsonItem(JsonItem.DictType) + for k,v in data.items(): + keyItem = self.itemFromJson(v) + keyItem.setData(0, keyItem.KeyRole, k) + item.addChild(keyItem) + else: + types = {bool: JsonItem.BoolType, int: JsonItem.IntType, float: JsonItem.FloatType, str: JsonItem.StringType} + jsonType = types.get(type(data), JsonItem.NoneType) + item = JsonItem(jsonType, data) + + return item diff --git a/utils.py b/utils.py index 1383d32..998bbba 100644 --- a/utils.py +++ b/utils.py @@ -67,4 +67,211 @@ def clearLayout(layout): widget.setParent(None) else: clearLayout(item.layout()) - \ No newline at end of file + +def getActions(menu, recursive=True): + actions = [] + for action in menu.actions(): + if action.menu() and recursive: + actions.extend(getActions(action.menu(), True)) + else: + actions.append(action) + return actions + +def setActionsLocalShortcut(widget): + for a in getActions(widget): + a.setShortcutContext(Qt.WidgetShortcut) + +def findOpeningBracketPosition(text, offset, brackets="{(["): + openingBrackets = "{([" + closingBrackets = "})]" + stack = [0 for i in range(len(openingBrackets))] # for each bracket set 0 as default + + if offset < 0 or offset >= len(text): + return None + + if text[offset] in closingBrackets: + offset -= 1 + + for i in range(offset, -1, -1): + c = text[i] + + if c in brackets and c in openingBrackets and stack[openingBrackets.index(c)] == 0: + return i + + elif c in openingBrackets: + stack[openingBrackets.index(c)] += 1 + + elif c in closingBrackets: + stack[closingBrackets.index(c)] -= 1 + +def findClosingBracketPosition(text, offset, brackets="})]"): + openingBrackets = "{([" + closingBrackets = "})]" + stack = [0 for _ in range(len(openingBrackets))] # for each bracket set 0 as default + + if offset < 0 or offset >= len(text): + return None + + if text[offset] in openingBrackets: + offset += 1 + + for i in range(offset, len(text)): + c = text[i] + + if c in brackets and c in closingBrackets and stack[closingBrackets.index(c)] == 0: + return i + + elif c in openingBrackets: + stack[openingBrackets.index(c)] += 1 + + elif c in closingBrackets: + stack[closingBrackets.index(c)] -= 1 + +def findBracketSpans(text, offset): + s = findOpeningBracketPosition(text, offset, "{([") + if s is not None: + matchingClosingBracket = {"{":"}", "(":")", "[":"]"}[text[s]] + e = findClosingBracketPosition(text, offset, matchingClosingBracket) + else: + e = findClosingBracketPosition(text, offset, "})]") + return (s,e) + +def wordAtCursor(cursor): + cursor = QTextCursor(cursor) + pos = cursor.position() + + lpart = "" + start = pos-1 + ch = cursor.document().characterAt(start) + while ch and re.match("[@\\w]", ch): + lpart += ch + start -= 1 + + if ch == "@": # @ can be the first character only + break + + ch = cursor.document().characterAt(start) + + rpart = "" + end = pos + ch = cursor.document().characterAt(end) + while ch and re.match("[\\w]", ch): + rpart += ch + end += 1 + ch = cursor.document().characterAt(end) + + return (lpart[::-1]+rpart, start+1, end) + +class SearchReplaceDialog(QDialog): + onReplace = Signal(str, str, dict) # old, new, options + + def __init__(self, options=[], **kwargs): + super().__init__(**kwargs) + + self.optionsWidgets = {} + + self.setWindowTitle("Search/Replace") + layout = QVBoxLayout() + self.setLayout(layout) + + self.searchWidget = QLineEdit("L_") + self.replaceWidget = QLineEdit("R_") + + btn = QPushButton("Replace") + btn.clicked.connect(self.replaceClicked) + + gridLayout = QGridLayout() + gridLayout.addWidget(QLabel("Search"),0,0) + gridLayout.addWidget(self.searchWidget,0,1) + gridLayout.addWidget(QLabel("Replace"),1,0) + gridLayout.addWidget(self.replaceWidget,1,1) + layout.addLayout(gridLayout) + + for opt in options: + w = QCheckBox(opt) + self.optionsWidgets[opt] = w + layout.addWidget(w) + + layout.addWidget(btn) + + def replaceClicked(self): + opts = {l:w.isChecked() for l,w in self.optionsWidgets.items()} + self.onReplace.emit(self.searchWidget.text(), self.replaceWidget.text(), opts) + self.accept() + +class SimpleUndo(): + def __init__(self): + self.undoEnabled = True + + self._undoStack = [] + self._undoTempStack = [] + self._tempEditBlockName = "" + self._undoOrder = 0 # undo inc/dec this + + def isEmpty(self): + return not self._undoStack + + def flush(self): + self._undoStack = [] + self._undoTempStack = [] + + def isInEditBlock(self): + return self._undoOrder > 0 + + def beginEditBlock(self, name="temp"): + self._tempEditBlockName = name + self._undoOrder += 1 + + def endEditBlock(self): + self._undoOrder -= 1 + + # append all temporary operations as a single undo function + if self._undoTempStack and not self.isInEditBlock(): + def f(stack=self._undoTempStack): + for _, func in stack: + func() + + self.push(self._tempEditBlockName, f) + self._undoTempStack = [] + + def getLastOperationName(self): + if not self._undoStack: + return + cmd = self._undoStack[-1][0] + return re.match(r"(.+)\s+#", cmd).group(1) + + def push(self, name, undoFunc, operationId=None): + def _getLastOperation(): + if self.isInEditBlock(): + return self._undoTempStack[-1] if self._undoTempStack else None + else: + return self._undoStack[-1] if self._undoStack else None + + if not self.undoEnabled: + return + + lastOp = _getLastOperation() + + cmd = "{} #{}".format(name, operationId) # generate unique command name + if operationId is not None and lastOp and lastOp[0] == cmd: # the same operation, do not add + pass + else: + if self.isInEditBlock(): + self._undoTempStack.append((cmd, undoFunc)) + else: + self._undoStack.append((cmd, undoFunc)) + + def undo(self): + if not self._undoStack: + print("Nothing to undo") + else: + self.undoEnabled = False # prevent undoing while undoing + + while True and self._undoStack: + _, undoFunc = self._undoStack.pop() + + if callable(undoFunc): + undoFunc() + break + + self.undoEnabled = True \ No newline at end of file diff --git a/widgets.py b/widgets.py index 28b4257..309ff1c 100644 --- a/widgets.py +++ b/widgets.py @@ -7,6 +7,8 @@ import json import math from .utils import * +from .editor import * +from .jsonWidget import JsonWidget DCC = os.getenv("RIG_BUILDER_DCC") or "maya" @@ -30,7 +32,7 @@ def fromSmartConversion(x): else: return json.dumps(x) if type(x) not in [str, unicode] else x -class TemplateWidget(QWidget): +class TemplateWidget(QFrame): somethingChanged = Signal() needUpdateUI = Signal() @@ -46,83 +48,91 @@ def getJsonData(self): def setJsonData(self, data): raise Exception("setJsonData must be implemented") - + class EditTextDialog(QDialog): - def __init__(self, text="", *, title="Edit", placeholder="", **kwargs): - super(EditTextDialog, self).__init__(**kwargs) + saved = Signal(str) # emitted when user clicks OK - self.outputText = text + def __init__(self, text="", *, title="Edit", placeholder="", python=False): + super().__init__(parent=QApplication.activeWindow()) self.setWindowTitle(title) + self.setGeometry(0, 0, 600, 400) layout = QVBoxLayout() self.setLayout(layout) - self.textWidget = QTextEdit() - self.textWidget.setPlainText(text) - self.textWidget.setTabStopWidth(16) - self.textWidget.setAcceptRichText(False) - self.textWidget.setWordWrapMode(QTextOption.NoWrap) + if not python: + self.textWidget = QTextEdit() + self.textWidget.setTabStopWidth(16) + self.textWidget.setAcceptRichText(False) + self.textWidget.setWordWrapMode(QTextOption.NoWrap) + else: + self.textWidget = CodeEditorWidget() + self.textWidget.setPlaceholderText(placeholder) + self.textWidget.setPlainText(text) okBtn = QPushButton("OK") - okBtn.clicked.connect(self.okBtnClicked) + okBtn.clicked.connect(self.saveAndClose) layout.addWidget(self.textWidget) layout.addWidget(okBtn) - def okBtnClicked(self): - self.outputText = self.textWidget.toPlainText() + centerWindow(self) + + def saveAndClose(self): + self.saved.emit(self.textWidget.toPlainText()) self.accept() class LabelTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(LabelTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) - self.actualText = "" + self._actualText = "" layout = QVBoxLayout() self.setLayout(layout) - layout.setMargin(0) + #layout.setContentsMargins(QMargins()) self.label = QLabel() self.label.setCursor(Qt.PointingHandCursor) self.label.setWordWrap(True) - self.label.setToolTip("You can use $ROOT as a path to Rig Builder's root directory, like $ROOT/images/icons") self.label.mouseDoubleClickEvent = self.labelDoubleClickEvent layout.addWidget(self.label) - def setText(self, text): - self.actualText = text - self.label.setText(self.actualText.replace("$ROOT", RootPath)) + def setLabelText(self, text): + self._actualText = text + self.label.setText(self._actualText.replace("$ROOT", RootPath)) def labelDoubleClickEvent(self, event): - editTextDialog = EditTextDialog(self.actualText, title="Edit text", placeholder="You can use HTML here...", parent=QApplication.activeWindow()) - editTextDialog.exec_() - - if editTextDialog.result(): - self.setText(editTextDialog.outputText) + def save(text): + self.setLabelText(text) self.somethingChanged.emit() + placeholder = 'Description' + editTextDialog = EditTextDialog(self._actualText, title="Edit text", placeholder=placeholder) + editTextDialog.saved.connect(save) + editTextDialog.show() + def getDefaultData(self): return {"text": "Description", "default": "text"} def getJsonData(self): - return {"text": self.actualText, "default": "text"} + return {"text": self._actualText, "default": "text"} def setJsonData(self, value): - self.setText(value["text"]) + self.setLabelText(value["text"]) class ButtonTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(ButtonTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) - self.buttonCommand = "module.attr.someAttr.set(1)" + self.buttonCommand = "" layout = QHBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.buttonWidget = QPushButton("Press me") self.buttonWidget.clicked.connect(self.buttonClicked) @@ -145,10 +155,13 @@ def editLabel(self): self.somethingChanged.emit() def editCommand(self): - editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder="Your python command...", parent=QApplication.activeWindow()) - editText.exec_() - self.buttonCommand = editText.outputText - self.somethingChanged.emit() + def save(text): + self.buttonCommand = text + self.somethingChanged.emit() + + editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder='chset("/someAttr", 1)', python=True) + editText.saved.connect(save) + editText.show() def buttonClicked(self): if self.buttonCommand: @@ -176,11 +189,11 @@ def setJsonData(self, data): class CheckBoxTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(CheckBoxTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) layout = QVBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.checkBox = QCheckBox() self.checkBox.stateChanged.connect(self.somethingChanged) @@ -194,11 +207,11 @@ def setJsonData(self, value): class ComboBoxTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(ComboBoxTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) layout = QVBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.comboBox = QComboBox() self.comboBox.currentIndexChanged.connect(self.somethingChanged) @@ -257,17 +270,14 @@ def setJsonData(self, value): class LineEditOptionsDialog(QDialog): def __init__(self, **kwargs): - super(LineEditOptionsDialog, self).__init__(**kwargs) + super().__init__(**kwargs) self.setWindowTitle("Edit options") layout = QVBoxLayout() self.setLayout(layout) - glayout = QGridLayout() - glayout.setDefaultPositioning(2, Qt.Horizontal) - glayout.setColumnStretch(1, 1) - + formLayout = QFormLayout() self.validatorWidget = QComboBox() self.validatorWidget.addItems(["Default", "Int", "Double"]) self.validatorWidget.currentIndexChanged.connect(self.validatorIndexChanged) @@ -283,16 +293,11 @@ def __init__(self, **kwargs): okBtn.clicked.connect(self.accept) okBtn.setAutoDefault(False) - glayout.addWidget(QLabel("Validator")) - glayout.addWidget(self.validatorWidget) - - glayout.addWidget(QLabel("Min")) - glayout.addWidget(self.minWidget) + formLayout.addRow("Validator", self.validatorWidget) + formLayout.addRow("Min", self.minWidget) + formLayout.addRow("Max", self.maxWidget) - glayout.addWidget(QLabel("Max")) - glayout.addWidget(self.maxWidget) - - layout.addLayout(glayout) + layout.addLayout(formLayout) layout.addWidget(okBtn) def validatorIndexChanged(self, idx): @@ -300,17 +305,20 @@ def validatorIndexChanged(self, idx): self.maxWidget.setEnabled(idx!=0) class LineEditTemplateWidget(TemplateWidget): + defaultMin = 0 + defaultMax = 100 + def __init__(self, **kwargs): - super(LineEditTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) self.optionsDialog = LineEditOptionsDialog(parent=self) - self.minValue = "" - self.maxValue = "" + self.minValue = 0 + self.maxValue = 100 self.validator = 0 layout = QHBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.textWidget = QLineEdit() self.textWidget.editingFinished.connect(self.textChanged) @@ -343,12 +351,12 @@ def textContextMenuEvent(self, event): menu.popup(event.globalPos()) def optionsClicked(self): - self.optionsDialog.minWidget.setText(self.minValue) - self.optionsDialog.maxWidget.setText(self.maxValue) + self.optionsDialog.minWidget.setText(str(self.minValue)) + self.optionsDialog.maxWidget.setText(str(self.maxValue)) self.optionsDialog.validatorWidget.setCurrentIndex(self.validator) self.optionsDialog.exec_() - self.minValue = self.optionsDialog.minWidget.text() - self.maxValue = self.optionsDialog.maxWidget.text() + self.minValue = int(self.optionsDialog.minWidget.text() or LineEditTemplateWidget.defaultMin) + self.maxValue = int(self.optionsDialog.maxWidget.text() or LineEditTemplateWidget.defaultMax) self.validator = self.optionsDialog.validatorWidget.currentIndex() self.setJsonData(self.getJsonData()) @@ -360,58 +368,61 @@ def getJsonData(self): "validator": self.validator} def setJsonData(self, data): - self.textWidget.setText(fromSmartConversion(data["value"])) - self.validator = data.get("validator", 0) - self.minValue = data.get("min") - self.maxValue = data.get("max") + self.minValue = int(data.get("min") or LineEditTemplateWidget.defaultMin) + self.maxValue = int(data.get("max") or LineEditTemplateWidget.defaultMax) if self.validator == 1: self.textWidget.setValidator(QIntValidator()) elif self.validator == 2: self.textWidget.setValidator(QDoubleValidator()) - if self.validator and self.minValue and self.maxValue: + if self.validator: self.sliderWidget.show() - - self.sliderWidget.setMinimum(int(self.minValue)*100) # slider values are int, so mult by 100 - self.sliderWidget.setMaximum(int(self.maxValue)*100) + if self.minValue: + self.sliderWidget.setMinimum(self.minValue*100) # slider values are int, so mult by 100 + if self.maxValue: + self.sliderWidget.setMaximum(self.maxValue*100) if data["value"]: self.sliderWidget.setValue(float(data["value"])*100) else: self.sliderWidget.hide() + self.textWidget.setText(fromSmartConversion(data["value"])) + class LineEditAndButtonTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(LineEditAndButtonTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) self.templates = {} if DCC == "maya": - self.templates["Get selected"] = "import maya.cmds as cmds\nls = cmds.ls(sl=True)\nif ls: value = ls[0]" + self.templates["Get selected"] = {"label": "<", "command":"import maya.cmds as cmds\nls = cmds.ls(sl=True)\nif ls: value = ls[0]"} - self.templates["Get open file"] = '''from PySide2.QtWidgets import QFileDialog;import os + self.templates["Get open file"] = {"label": "...", "command":'''from PySide2.QtWidgets import QFileDialog;import os path,_ = QFileDialog.getOpenFileName(None, "Open file", os.path.expandvars(value)) -value = path or value''' +value = path or value'''} - self.templates["Get save file"] = '''from PySide2.QtWidgets import QFileDialog;import os + self.templates["Get save file"] = {"label": "...", "command":'''from PySide2.QtWidgets import QFileDialog;import os path,_ = QFileDialog.getSaveFileName(None, "Save file", os.path.expandvars(value)) -value = path or value''' +value = path or value'''} - self.templates["Get existing directory"] = '''from PySide2.QtWidgets import QFileDialog;import os + self.templates["Get existing directory"] = {"label": "...", "command":'''from PySide2.QtWidgets import QFileDialog;import os path = QFileDialog.getExistingDirectory(None, "Select directory", os.path.expandvars(value)) -value = path or value''' +value = path or value'''} + + defaultCmd = self.templates["Get selected"] - self.buttonCommand = self.templates.get("Get selected", "value = 'something'") + self.buttonCommand = defaultCmd["command"] layout = QHBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.textWidget = QLineEdit() self.textWidget.editingFinished.connect(self.somethingChanged) - self.buttonWidget = QPushButton("<") + self.buttonWidget = QPushButton(defaultCmd["label"]) self.buttonWidget.clicked.connect(self.buttonClicked) self.buttonWidget.contextMenuEvent = self.buttonContextMenuEvent @@ -425,13 +436,14 @@ def buttonContextMenuEvent(self, event): menu.addAction("Edit command", self.editCommand) if self.templates: - def setText(cmd): - self.buttonCommand = cmd + def setCommand(cmd): + self.buttonWidget.setText(cmd["label"]) + self.buttonCommand = cmd["command"] self.somethingChanged.emit() templatesMenu = QMenu("Templates", self) - for k in self.templates: - templatesMenu.addAction(k, lambda cmd=self.templates[k]:setText(cmd)) + for k, cmd in self.templates.items(): + templatesMenu.addAction(k, lambda cmd=cmd:setCommand(cmd)) menu.addMenu(templatesMenu) menu.popup(event.globalPos()) @@ -443,10 +455,13 @@ def editLabel(self): self.somethingChanged.emit() def editCommand(self): - editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder="Your python command...", parent=QApplication.activeWindow()) - editText.exec_() - self.buttonCommand = editText.outputText - self.somethingChanged.emit() + def save(text): + self.buttonCommand = text + self.somethingChanged.emit() + + editText = EditTextDialog(self.buttonCommand, title="Edit command", placeholder="Your python command...", python=True) + editText.saved.connect(save) + editText.show() def buttonClicked(self): if self.buttonCommand: @@ -476,11 +491,11 @@ def setJsonData(self, data): class ListBoxTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(ListBoxTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) layout = QVBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.listWidget = QListWidget() self.listWidget.itemDoubleClicked.connect(self.itemDoubleClicked) @@ -504,11 +519,11 @@ def listContextMenuEvent(self, event): menu.popup(event.globalPos()) def resizeWidget(self): - width = self.listWidget.sizeHintForColumn(0) + 25 + width = self.listWidget.sizeHintForColumn(0) + 50 height = 0 for i in range(self.listWidget.count()): height += self.listWidget.sizeHintForRow(i) - height += 2*self.listWidget.frameWidth() + 25 + height += 2*self.listWidget.frameWidth() + 50 self.listWidget.setFixedSize(clamp(width, 100, 500), clamp(height, 100, 500)) def editItem(self): @@ -577,11 +592,13 @@ class RadioButtonTemplateWidget(TemplateWidget): Columns = [2,3,4,5] def __init__(self, **kwargs): - super(RadioButtonTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) + + self.numColumns = 3 layout = QGridLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.buttonsGroupWidget = QButtonGroup() self.buttonsGroupWidget.buttonClicked.connect(self.buttonClicked) @@ -615,8 +632,7 @@ def buttonClicked(self, b): self.somethingChanged.emit() def clearButtons(self): - gridLayout = self.layout() - clearLayout(gridLayout) + clearLayout(self.layout()) for b in self.buttonsGroupWidget.buttons(): self.buttonsGroupWidget.removeButton(b) @@ -631,12 +647,12 @@ def editClicked(self): self.somethingChanged.emit() def getDefaultData(self): - return {"items": ["Helpers", "Run"], "current": 0, "default": "current", "columns":3} + return {"items": ["Helpers", "Run"], "current": 0, "default": "current", "columns": self.numColumns} def getJsonData(self): return {"items": [b.text() for b in self.buttonsGroupWidget.buttons()], "current": self.buttonsGroupWidget.checkedId(), - "columns": self.layout().columnCount(), + "columns": self.numColumns, "default": "current"} def setJsonData(self, value): @@ -644,13 +660,13 @@ def setJsonData(self, value): self.clearButtons() - columns = value.get("columns", 3) - gridLayout.setDefaultPositioning(columns, Qt.Horizontal) + self.numColumns = value["columns"] + gridLayout.setDefaultPositioning(self.numColumns, Qt.Horizontal) row = 0 column = 0 for i, item in enumerate(value["items"]): - if i % columns == 0 and i > 0: + if i % self.numColumns == 0 and i > 0: row += 1 column = 0 @@ -666,11 +682,11 @@ def setJsonData(self, value): class TableTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(TableTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) layout = QVBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.tableWidget = QTableWidget() self.tableWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) @@ -832,51 +848,77 @@ def setJsonData(self, value): class TextTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(TextTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) layout = QVBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.textWidget = QTextEdit() self.textWidget.textChanged.connect(self.somethingChanged) + incSizeBtn = QPushButton("+") + incSizeBtn.setFixedSize(25, 25) + incSizeBtn.clicked.connect(self.incSize) + decSizeBtn = QPushButton("-") + decSizeBtn.setFixedSize(25, 25) + decSizeBtn.clicked.connect(self.decSize) + + hlayout = QHBoxLayout() + hlayout.addWidget(decSizeBtn) + hlayout.addWidget(incSizeBtn) + hlayout.addStretch() + + layout.addLayout(hlayout) layout.addWidget(self.textWidget) + layout.addStretch() + + def incSize(self): + self.textWidget.setFixedHeight(self.textWidget.height() + 50) + self.somethingChanged.emit() + + def decSize(self): + self.textWidget.setFixedHeight(self.textWidget.height() - 50) + self.somethingChanged.emit() + + def getDefaultData(self): + return {"text": "", "height": 200, "default": "text"} def getJsonData(self): return {"text": self.textWidget.toPlainText().strip(), + "height": self.textWidget.height(), "default": "text"} def setJsonData(self, data): self.textWidget.setPlainText(data["text"]) + self.textWidget.setFixedHeight(data.get("height", self.getDefaultData()["height"])) class VectorTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(VectorTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) layout = QHBoxLayout() self.setLayout(layout) - layout.setMargin(0) + layout.setContentsMargins(QMargins()) - self.xWidget = QLineEdit("0") + self.xWidget = QLineEdit() self.xWidget.setValidator(QDoubleValidator()) - self.xWidget.editingFinished.connect(self.somethingChanged) - - self.yWidget = QLineEdit("0") + self.xWidget.editingFinished.connect(self.somethingChanged.emit) + + self.yWidget = QLineEdit() self.yWidget.setValidator(QDoubleValidator()) - self.yWidget.editingFinished.connect(self.somethingChanged) + self.yWidget.editingFinished.connect(self.somethingChanged.emit) - self.zWidget = QLineEdit("0") + self.zWidget = QLineEdit() self.zWidget.setValidator(QDoubleValidator()) - self.zWidget.editingFinished.connect(self.somethingChanged) + self.zWidget.editingFinished.connect(self.somethingChanged.emit) layout.addWidget(self.xWidget) layout.addWidget(self.yWidget) layout.addWidget(self.zWidget) 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(self.xWidget.text() or 0), float(self.yWidget.text() or 0), float(self.zWidget.text() or 0)], "default": "value"} def setJsonData(self, value): self.xWidget.setText(str(value["value"][0])) @@ -943,12 +985,12 @@ def evaluateBezierCurveFromX(cvs, x): def normalizedPoint(p, minX, maxX, minY, maxY): x = (p[0] - minX) / (maxX - minX) y = (p[1] - minY) / (maxY - minY) - return (x, y) + return [x, y] class CurvePointItem(QGraphicsItem): Size = 10 def __init__(self, **kwargs): - super(CurvePointItem, self).__init__(**kwargs) + super().__init__(**kwargs) self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges) @@ -969,7 +1011,7 @@ def paint(self, painter, option, widget): def itemChange(self, change, value): if not self.scene(): - return super(CurvePointItem, self).itemChange(change, value) + return super().itemChange(change, value) if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: if self.fixedX is not None: @@ -1010,14 +1052,14 @@ def itemChange(self, change, value): if type(view) == CurveView: view.somethingChanged.emit() - return super(CurvePointItem, self).itemChange(change, value) + return super().itemChange(change, value) class CurveScene(QGraphicsScene): MaxX = 300 MaxY = -100 DrawCurveSamples = 33 def __init__(self, **kwargs): - super(CurveScene, self).__init__(**kwargs) + super().__init__(**kwargs) self.cvs = [] @@ -1077,7 +1119,7 @@ def mousePressEvent(self, event): event.accept() else: - super(CurveScene,self).mousePressEvent(event) + super().mousePressEvent(event) def calculateCVs(self): self.cvs = [] @@ -1108,7 +1150,7 @@ def calculateCVs(self): w = max(w1, w2)*2 - 1 # from 0 to 1, because max(w1,w2) is always >= 0.5 w = w ** 4 tg = QVector2D(items[i+1].pos() - items[i-1].pos()).normalized() * (1-w) + QVector2D(1, 0) * w - + tangents.append(tg) for i, _ in enumerate(items): @@ -1192,7 +1234,7 @@ class CurveView(QGraphicsView): somethingChanged = Signal() def __init__(self, **kwargs): - super(CurveView, self).__init__(**kwargs) + super().__init__(**kwargs) self.setRenderHint(QPainter.Antialiasing, True) self.setRenderHint(QPainter.TextAntialiasing, True) @@ -1211,10 +1253,10 @@ def resizeEvent(self, event): class CurveTemplateWidget(TemplateWidget): def __init__(self, **kwargs): - super(CurveTemplateWidget, self).__init__(**kwargs) + super().__init__(**kwargs) layout = QVBoxLayout() - layout.setMargin(0) + layout.setContentsMargins(QMargins()) self.setLayout(layout) self.curveView = CurveView() @@ -1222,7 +1264,9 @@ def __init__(self, **kwargs): layout.addWidget(self.curveView) def getDefaultData(self): - return {'default': 'cvs', 'cvs': [(0.0, 1.0), (0.13973423457023273, 0.722154453101879), (0.3352803473835302, -0.0019584480764515554), (0.5029205210752953, -0.0), (0.6686136807168636, 0.0019357021806590401), (0.8623842449806401, 0.7231513901834298), (1.0, 1.0)]} + return {'default': 'cvs', 'cvs': [[0.0, 1.0], [0.13973423457023273, 0.722154453101879], + [0.3352803473835302, -0.0019584480764515554], [0.5029205210752953, -0.0], + [0.6686136807168636, 0.0019357021806590401], [0.8623842449806401, 0.7231513901834298], [1.0, 1.0]]} def getJsonData(self): return {"cvs": self.curveView.scene().cvs, "default": "cvs"} @@ -1240,11 +1284,81 @@ def setJsonData(self, value): if i == 0 or i == len(value["cvs"]) - 1: item.fixedX = item.pos().x() +class JsonTemplateWidget(TemplateWidget): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + layout = QVBoxLayout() + self.setLayout(layout) + layout.setContentsMargins(QMargins()) + + self.jsonWidget = JsonWidget() + self.jsonWidget.itemChanged.connect(lambda _,__:self.somethingChanged.emit()) + self.jsonWidget.itemMoved.connect(lambda _:self.somethingChanged.emit()) + self.jsonWidget.itemAdded.connect(lambda _:self.somethingChanged.emit()) + self.jsonWidget.itemRemoved.connect(lambda _:self.somethingChanged.emit()) + self.jsonWidget.dataLoaded.connect(self.somethingChanged.emit) + self.jsonWidget.cleared.connect(self.somethingChanged.emit) + self.jsonWidget.readOnlyChanged.connect(lambda _: self.somethingChanged.emit()) + self.jsonWidget.rootChanged.connect(lambda _: self.updateInfoLabel()) + self.jsonWidget.itemClicked.connect(lambda _: self.updateInfoLabel()) + + incSizeBtn = QPushButton("+") + incSizeBtn.setFixedSize(25, 25) + incSizeBtn.clicked.connect(self.incSize) + decSizeBtn = QPushButton("-") + decSizeBtn.setFixedSize(25, 25) + decSizeBtn.clicked.connect(self.decSize) + + self.infoLabel = QLabel() + + hlayout = QHBoxLayout() + hlayout.addWidget(decSizeBtn) + hlayout.addWidget(incSizeBtn) + hlayout.addStretch() + hlayout.addWidget(self.infoLabel) + + layout.addLayout(hlayout) + layout.addWidget(self.jsonWidget) + layout.addStretch() + + def updateInfoLabel(self): + rootIndex = self.jsonWidget.rootIndex() + root = self.jsonWidget.itemFromIndex(rootIndex).getPath() if rootIndex != QModelIndex() else "" + + item = self.jsonWidget.selectedItem() + path = item.getPath() if item else "" + self.infoLabel.setText("Root:{} Path:{}".format(root, path.replace(root,""))) + + def incSize(self): + self.jsonWidget.setFixedHeight(self.jsonWidget.height() + 50) + self.somethingChanged.emit() + + def decSize(self): + self.jsonWidget.setFixedHeight(self.jsonWidget.height() - 50) + self.somethingChanged.emit() + + def getDefaultData(self): + return {"data": [{"a": 1, "b": 2}], "height":200, "readonly": False, "default": "data"} + + def getJsonData(self): + return {"data": self.jsonWidget.toJsonList(), + "height":self.jsonWidget.height(), + "readonly": self.jsonWidget.isReadOnly(), + "default": "data"} + + def setJsonData(self, value): + self.jsonWidget.setFixedHeight(value["height"]) + self.jsonWidget.clear() + self.jsonWidget.fromJsonList(value["data"]) + self.jsonWidget.setReadOnly(value["readonly"]) + TemplateWidgets = { "button": ButtonTemplateWidget, "checkBox": CheckBoxTemplateWidget, "comboBox": ComboBoxTemplateWidget, "curve": CurveTemplateWidget, + "json": JsonTemplateWidget, "label": LabelTemplateWidget, "lineEdit": LineEditTemplateWidget, "lineEditAndButton": LineEditAndButtonTemplateWidget,