From ef1b5fb8d0aae90ec47424b03337b619cfc65a76 Mon Sep 17 00:00:00 2001 From: azagoruyko Date: Fri, 19 Jul 2024 09:06:12 +0200 Subject: [PATCH] COMPLETE REFACTORING. v2.0.0 Compatible with v1.0.0 editor: highly improved search/replace dialog with flags (f3 key). add menu for duplicate and move lines. fix bug with completion widget and scrolling. fix bug with move line at the first line. place common code in utils.py. refactor code, make it more compatible with python 3+. classes: add attribute expressions (python code). explicit declare modification flag here. widgets: add high featured json widget for editing any json data. use code editor for editing button commands. use label/code templates for button widget. improve generic UI experience, add placeholders. add increase/decrease size for text widget. fix curve default value (get rid of tuples). show slider for lineEdit when a validator is used, default min/max 0-100. fix radioButton widget column number selector. a lot of code refactoring. ui: edit attributes expressions within their context menu. edit attributes data with json widget. improve modification flags experience, now works more consistent. highly improved visual style for attributes with connection/expression. add ModuleItem which solely work with Module class using Qt data mechanics. remove main menu, use instead improved context menu. add copy tool code menu to obtain the code for running the module as a tool. RigBuilderTool arguments fixes: child supports int/str, size supports number/list[2] to specify the tool's window size. completely refactored code to make it more compatible with python 3+. utils: common functions/classes live here. --- __init__.py | 875 +++++++++++++++++++++++++++----------------------- classes.py | 62 +++- editor.py | 696 ++++++++++++++++----------------------- jsonWidget.py | 670 ++++++++++++++++++++++++++++++++++++++ utils.py | 209 +++++++++++- widgets.py | 366 +++++++++++++-------- 6 files changed, 1920 insertions(+), 958 deletions(-) create mode 100644 jsonWidget.py 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,