class ActionForm(BuildItemForm): """ Form for editing Actions that displays an attr form for every attribute on the action. """ convertToBatchClicked = QtCore.Signal() def setupUi(self, parent): super(ActionForm, self).setupUi(parent) # add batch conversion button to header convertToBatchBtn = QtWidgets.QPushButton(parent) convertToBatchBtn.setIcon( viewutils.getIcon("convertActionToBatch.png")) convertToBatchBtn.setFixedSize(QtCore.QSize(18, 18)) convertToBatchBtn.clicked.connect(self.convertToBatchClicked.emit) self.headerLayout.addWidget(convertToBatchBtn) def setupContentUi(self, parent): for attr in self.buildItem.config['attrs']: attrValue = getattr(self.buildItem, attr['name']) attrForm = ActionAttrForm.createForm(attr, attrValue, parent=parent) attrForm.valueChanged.connect( partial(self.attrValueChanged, attrForm)) self.mainLayout.addWidget(attrForm) def attrValueChanged(self, attrForm, attrValue, isValueValid): setattr(self.buildItem, attrForm.attr['name'], attrValue) self.buildItemChanged.emit()
class CollapsibleFrame(QtWidgets.QFrame): """ A QFrame that can be collapsed when clicked. """ collapsedChanged = QtCore.Signal(bool) def __init__(self, parent): super(CollapsibleFrame, self).__init__(parent) self._isCollapsed = False def mouseReleaseEvent(self, QMouseEvent): if QMouseEvent.button() == QtCore.Qt.MouseButton.LeftButton: self.setIsCollapsed(not self._isCollapsed) else: return super(CollapsibleFrame, self).mouseReleaseEvent(QMouseEvent) def setIsCollapsed(self, newCollapsed): """ Set the collapsed state of this frame. """ self._isCollapsed = newCollapsed self.collapsedChanged.emit(self._isCollapsed) def isCollapsed(self): """ Return True if the frame is currently collapsed. """ return self._isCollapsed
class BatchAttrForm(QtWidgets.QWidget): """ The base class for an attribute form designed to bulk edit all variants of an attribute on a batch action. This appears where the default attr form usually appears when the attribute is marked as variant. BatchAttrForms should only exist if they provide an easy way to bulk set different values for all variants, as its pointless to provide functionality for setting all variants to the same value (would make the attribute constant). """ TYPEMAP = {} valuesChanged = QtCore.Signal() variantCountChanged = QtCore.Signal() @staticmethod def doesFormExist(attr): return attr['type'] in BatchAttrForm.TYPEMAP @staticmethod def createForm(action, attr, parent=None): """ Create a new ActionAttrForm of the appropriate type based on a BuildAction attribute. Args: attr: A dict representing the config of a BuildAction attribute """ attrType = attr['type'] if attrType in BatchAttrForm.TYPEMAP: return BatchAttrForm.TYPEMAP[attrType](action, attr, parent=parent) def __init__(self, batchAction, attr, parent=None): super(BatchAttrForm, self).__init__(parent=parent) self.batchAction = batchAction self.attr = attr self.setupUi(self) def setupUi(self, parent): raise NotImplementedError
class CollapsibleFrame(QtWidgets.QFrame): collapsedChanged = QtCore.Signal(bool) def __init__(self, parent): super(CollapsibleFrame, self).__init__(parent) self._isCollapsed = False def mouseReleaseEvent(self, QMouseEvent): if QMouseEvent.button() == QtCore.Qt.MouseButton.LeftButton: self.setIsCollapsed(not self._isCollapsed) else: return super(CollapsibleFrame, self).mouseReleaseEvent(QMouseEvent) def setIsCollapsed(self, newCollapsed): self._isCollapsed = newCollapsed self.collapsedChanged.emit(self._isCollapsed) def isCollapsed(self): return self._isCollapsed
class BlueprintUIModel(QtCore.QObject): """ The owner and manager of various models representing a Blueprint in the scene. All reading and writing for the Blueprint through the UI should be done using this model. """ # shared instances, mapped by name INSTANCES = {} @classmethod def getDefaultModel(cls): return cls.getSharedModel(None) @classmethod def getSharedModel(cls, name): """ Return a shared UI model by name, creating a new model if necessary. Will always return a valid BlueprintUIModel. """ if name not in cls.INSTANCES: cls.INSTANCES[name] = cls(name) return cls.INSTANCES[name] @classmethod def deleteSharedModel(cls, name): if name in cls.INSTANCES: del cls.INSTANCES[name] # a config property on the blueprint changed # TODO: add more generic blueprint property data model rigNameChanged = QtCore.Signal(str) def __init__(self, parent=None): super(BlueprintUIModel, self).__init__(parent=parent) # the blueprint of this model self.blueprint = Blueprint() # the tree item model and selection model for BuildItems self.buildStepTreeModel = BuildStepTreeModel(self.blueprint) self.buildStepSelectionModel = BuildStepSelectionModel( self.buildStepTreeModel) # attempt to load from the scene self.loadFromFile(suppressWarnings=True) def isReadOnly(self): """ Return True if the Blueprint is not able to be modified. """ return False def getBlueprintFilepath(self): """ Return the filepath for the Blueprint being edited """ sceneName = pm.sceneName() if not sceneName: return None filepath = os.path.splitext(sceneName)[0] + '.yaml' return filepath def saveToFile(self, suppressWarnings=False): """ Save the Blueprint data to the file associated with this model """ filepath = self.getBlueprintFilepath() if not filepath: if not suppressWarnings: LOG.warning("Scene is not saved") return success = self.blueprint.saveToFile(filepath) if not success: LOG.error("Failed to save Blueprint to file: {0}".format(filepath)) def loadFromFile(self, suppressWarnings=False): """ Load the Blueprint from the file associated with this model """ filepath = self.getBlueprintFilepath() if not filepath: if not suppressWarnings: LOG.warning("Scene is not saved") return success = self.blueprint.loadFromFile(filepath) self.emitAllModelResets() if not success: LOG.error( "Failed to load Blueprint from file: {0}".format(filepath)) def emitAllModelResets(self): self.buildStepTreeModel.modelReset.emit() self.rigNameChanged.emit(self.getRigName()) def getRigName(self): return self.blueprint.rigName def setRigName(self, newRigName): if not self.isReadOnly(): self.blueprint.rigName = newRigName self.rigNameChanged.emit(self.blueprint.rigName) def initializeBlueprint(self): """ Initialize the Blueprint to its default state. """ self.blueprint.rootStep.clearChildren() self.blueprint.initializeDefaultActions() self.emitAllModelResets() def getActionDataForAttrPath(self, attrPath): """ Return serialized data for an action represented by an attribute path. """ stepPath, _ = attrPath.split('.') step = self.blueprint.getStepByPath(stepPath) if not step.isAction(): LOG.error( 'getActionDataForAttrPath: {0} is not an action'.format(step)) return return step.actionProxy.serialize() def setActionDataForAttrPath(self, attrPath, data): """ Replace all values on an action represented by an attribute path by deserializng data. """ stepPath, _ = attrPath.split('.') step = self.blueprint.getStepByPath(stepPath) if not step.isAction(): LOG.error( 'setActionDataForAttrPath: {0} is not an action'.format(step)) return step.actionProxy.deserialize(data) index = self.buildStepTreeModel.indexByStepPath(stepPath) self.buildStepTreeModel.dataChanged.emit(index, index, []) def getActionAttr(self, attrPath, variantIndex=-1): stepPath, attrName = attrPath.split('.') step = self.blueprint.getStepByPath(stepPath) if not step: LOG.error("Could not find step: {0}".format(stepPath)) return if not step.isAction(): LOG.error('getActionAttr: {0} is not an action'.format(step)) return if variantIndex >= 0: if step.actionProxy.numVariants() > variantIndex: actionData = step.actionProxy.getVariant(variantIndex) return actionData.getAttrValue(attrName) else: return step.actionProxy.getAttrValue(attrName) def setActionAttr(self, attrPath, value, variantIndex=-1): """ Set the value for an attribute on the Blueprint """ if self.isReadOnly(): return stepPath, attrName = attrPath.split('.') step = self.blueprint.getStepByPath(stepPath) if not step.isAction(): LOG.error('setActionAttr: {0} is not an action'.format(step)) return if variantIndex >= 0: variant = step.actionProxy.getOrCreateVariant(variantIndex) variant.setAttrValue(attrName, value) else: step.actionProxy.setAttrValue(attrName, value) index = self.buildStepTreeModel.indexByStepPath(stepPath) self.buildStepTreeModel.dataChanged.emit(index, index, []) def isActionAttrVariant(self, attrPath): stepPath, attrName = attrPath.split('.') step = self.blueprint.getStepByPath(stepPath) if not step.isAction(): LOG.error("isActionAttrVariant: {0} is not an action".format(step)) return return step.actionProxy.isVariantAttr(attrName) def setIsActionAttrVariant(self, attrPath, isVariant): """ """ if self.isReadOnly(): return stepPath, attrName = attrPath.split('.') step = self.blueprint.getStepByPath(stepPath) if not step.isAction(): LOG.error( "setIsActionAttrVariant: {0} is not an action".format(step)) return step.actionProxy.setIsVariantAttr(attrName, isVariant) index = self.buildStepTreeModel.indexByStepPath(stepPath) self.buildStepTreeModel.dataChanged.emit(index, index, []) def moveStep(self, sourcePath, targetPath): """ Move a BuildStep from source path to target path. Returns: The new path (str) of the build step, or None if the operation failed. """ if self.isReadOnly(): return step = self.blueprint.getStepByPath(sourcePath) if not step: LOG.error("moveStep: failed to find step: {0}".format(sourcePath)) return if step == self.blueprint.rootStep: LOG.error("moveStep: cannot move root step") return index = self.buildStepTreeModel.indexByStepPath(sourcePath) # TODO: handle moving between new parents newName = targetPath.split('/')[-1] step.setName(newName) self.buildStepTreeModel.dataChanged.emit(index, index, []) return step.getFullPath()
class BlueprintUIModel(QtCore.QObject): """ The owner and manager of various models representing a Blueprint in the scene. All reading and writing for the Blueprint through the UI should be done using this model. Blueprints edited though the UI are saved to yaml files associated with a maya scene file. This model is also responsible for loading and saving the blueprint yaml file. """ # shared instances, mapped by name INSTANCES = {} @classmethod def getDefaultModel(cls): # type: (class) -> BlueprintUIModel """ Return the default model instance used by editor views. """ return cls.getSharedModel(None) @classmethod def getSharedModel(cls, name): # type: (class, str) -> BlueprintUIModel """ Return a shared UI model by name, creating a new model if necessary. Will always return a valid BlueprintUIModel. """ if name not in cls.INSTANCES: cls.INSTANCES[name] = cls(name) return cls.INSTANCES[name] @classmethod def deleteSharedModel(cls, name): if name in cls.INSTANCES: cls.INSTANCES[name].onDelete() del cls.INSTANCES[name] @classmethod def deleteAllSharedModels(cls): keys = cls.INSTANCES.keys() for key in keys: cls.INSTANCES[key].onDelete() del cls.INSTANCES[key] # a config property on the blueprint changed # TODO: add more generic blueprint property data model rigNameChanged = QtCore.Signal(str) rigExistsChanged = QtCore.Signal() autoSave = optionVarProperty('pulse.editor.autoSave', True) autoLoad = optionVarProperty('pulse.editor.autoLoad', True) def setAutoSave(self, value): self.autoSave = value def setAutoLoad(self, value): self.autoLoad = value def __init__(self, parent=None): super(BlueprintUIModel, self).__init__(parent=parent) # the blueprint of this model self._blueprint = Blueprint() # the tree item model and selection model for BuildSteps self.buildStepTreeModel = BuildStepTreeModel(self.blueprint, self) self.buildStepSelectionModel = BuildStepSelectionModel( self.buildStepTreeModel, self) # keeps track of whether a rig is currently in the scene, # which will affect the ability to edit the Blueprint self.rigExists = len(pulse.getAllRigs()) > 0 # attempt to load from the scene if self.autoLoad: self.load(suppressWarnings=True) # register maya scene callbacks that can be used # for auto-saving the Blueprint self._callbackIds = [] self.addSceneCallbacks() def onDelete(self): self.removeSceneCallbacks() @property def blueprint(self): """ The Blueprint object represented by this Model. """ return self._blueprint def isReadOnly(self): """ Return True if the Blueprint is not able to be modified. """ return self.rigExists def getBlueprintFilepath(self): """ Return the filepath for the Blueprint being edited """ sceneName = None if self.rigExists: # get filepath from rig rig = pulse.getAllRigs()[0] rigdata = meta.getMetaData(rig, pulse.RIG_METACLASS) sceneName = rigdata.get('blueprintFile') else: sceneName = pm.sceneName() if sceneName: filepath = os.path.splitext(sceneName)[0] + '.yaml' return filepath def addSceneCallbacks(self): if not self._callbackIds: saveId = api.MSceneMessage.addCallback( api.MSceneMessage.kBeforeSave, self.onBeforeSaveScene) openId = api.MSceneMessage.addCallback( api.MSceneMessage.kAfterOpen, self.onAfterOpenScene) newId = api.MSceneMessage.addCallback( api.MSceneMessage.kAfterNew, self.onAfterNewScene) self._callbackIds.append(saveId) self._callbackIds.append(openId) self._callbackIds.append(newId) LOG.debug( 'BlueprintUIModel: added scene callbacks') def removeSceneCallbacks(self): if self._callbackIds: while self._callbackIds: cbid = self._callbackIds.pop() api.MMessage.removeCallback(cbid) LOG.debug( 'BlueprintUIModel: removed scene callbacks') def onBeforeSaveScene(self, clientData=None): if self.autoSave: self.refreshRigExists() if not self.isReadOnly(): LOG.debug('Auto-saving Pulse Blueprint...') self.save() def onAfterOpenScene(self, clientData=None): if self.autoLoad: self.load(suppressWarnings=True) def onAfterNewScene(self, clientData=None): self.initializeBlueprint() def save(self, suppressWarnings=False): """ Save the Blueprint data to the file associated with this model """ self.refreshRigExists() if self.isReadOnly(): return filepath = self.getBlueprintFilepath() if not filepath: if not suppressWarnings: LOG.warning("Scene is not saved") return success = self.blueprint.saveToFile(filepath) if not success: LOG.error("Failed to save Blueprint to file: {0}".format(filepath)) def load(self, suppressWarnings=False): """ Load the Blueprint from the file associated with this model """ self.refreshRigExists() filepath = self.getBlueprintFilepath() if not filepath: if not suppressWarnings: LOG.warning("Scene is not saved") self.initializeBlueprint() return if not os.path.isfile(filepath): if not suppressWarnings: LOG.warning( "Blueprint file does not exist: {0}".format(filepath)) return self.buildStepTreeModel.beginResetModel() success = self.blueprint.loadFromFile(filepath) self.buildStepTreeModel.endResetModel() self.rigNameChanged.emit(self.getRigName()) if not success: LOG.error( "Failed to load Blueprint from file: {0}".format(filepath)) def refreshRigExists(self): self.rigExists = len(pulse.getAllRigs()) > 0 self.rigExistsChanged.emit() def getRigName(self): return self.blueprint.rigName def setRigName(self, newRigName): if self.isReadOnly(): LOG.error('Cannot edit readonly Blueprint') return self.blueprint.rigName = newRigName self.rigNameChanged.emit(self.blueprint.rigName) def initializeBlueprint(self): """ Initialize the Blueprint to an empty state. """ self.buildStepTreeModel.beginResetModel() self.blueprint.rigName = None self.blueprint.rootStep.clearChildren() self.buildStepTreeModel.endResetModel() self.rigNameChanged.emit(self.blueprint.rigName) def initializeBlueprintToDefaultActions(self): """ Initialize the Blueprint to its default state based on the current blueprint config. """ self.buildStepTreeModel.beginResetModel() self.blueprint.rootStep.clearChildren() self.blueprint.initializeDefaultActions() self.buildStepTreeModel.endResetModel() def createStep(self, parentPath, childIndex, data): """ Create a new BuildStep Args: parentPath (str): The path to the parent step childIndex (int): The index at which to insert the new step data (str): The serialized data for the BuildStep to create Returns: The newly created BuildStep, or None if the operation failed. """ if self.isReadOnly(): LOG.error('Cannot edit readonly Blueprint') return parentStep = self.blueprint.getStepByPath(parentPath) if not parentStep: LOG.error("createStep: failed to find parent step: %s", parentPath) return parentIndex = self.buildStepTreeModel.indexByStep(parentStep) self.buildStepTreeModel.beginInsertRows( parentIndex, childIndex, childIndex) step = BuildStep.fromData(data) parentStep.insertChild(childIndex, step) self.buildStepTreeModel.endInsertRows() return step def deleteStep(self, stepPath): """ Delete a BuildStep Returns: True if the step was deleted successfully """ if self.isReadOnly(): LOG.error('Cannot edit readonly Blueprint') return False step = self.blueprint.getStepByPath(stepPath) if not step: LOG.error("deleteStep: failed to find step: %s", stepPath) return False stepIndex = self.buildStepTreeModel.indexByStep(step) self.buildStepTreeModel.beginRemoveRows( stepIndex.parent(), stepIndex.row(), stepIndex.row()) step.removeFromParent() self.buildStepTreeModel.endRemoveRows() return True def moveStep(self, sourcePath, targetPath): """ Move a BuildStep from source path to target path. Returns: The new path (str) of the build step, or None if the operation failed. """ if self.isReadOnly(): LOG.error('Cannot edit readonly Blueprint') return step = self.blueprint.getStepByPath(sourcePath) if not step: LOG.error("moveStep: failed to find step: %s", sourcePath) return if step == self.blueprint.rootStep: LOG.error("moveStep: cannot move root step") return self.buildStepTreeModel.layoutAboutToBeChanged.emit() sourceParentPath = os.path.dirname(sourcePath) targetParentPath = os.path.dirname(targetPath) if sourceParentPath != targetParentPath: step.setParent(self.blueprint.getStepByPath(targetParentPath)) targetName = os.path.basename(targetPath) step.setName(targetName) self.buildStepTreeModel.layoutChanged.emit() return step.getFullPath() def renameStep(self, stepPath, targetName): if self.isReadOnly(): LOG.error('Cannot edit readonly Blueprint') return step = self.blueprint.getStepByPath(stepPath) if not step: LOG.error("moveStep: failed to find step: %s", stepPath) return if step == self.blueprint.rootStep: LOG.error("moveStep: cannot rename root step") return oldName = step.name step.setName(targetName) if step.name != oldName: index = self.buildStepTreeModel.indexByStep(step) self.buildStepTreeModel.dataChanged.emit(index, index, []) return step.getFullPath() def getStep(self, stepPath): """ Return the BuildStep at a path """ return self.blueprint.getStepByPath(stepPath) def getStepData(self, stepPath): """ Return the serialized data for a step at a path """ step = self.getStep(stepPath) if step: return step.serialize() def getActionData(self, stepPath): """ Return serialized data for a BuildActionProxy """ step = self.getStep(stepPath) if not step: return if not step.isAction(): LOG.error( 'getActionData: %s step is not an action', step) return return step.actionProxy.serialize() def setActionData(self, stepPath, data): """ Replace all attribute values on a BuildActionProxy. """ step = self.getStep(stepPath) if not step: return if not step.isAction(): LOG.error( 'setActionData: %s step is not an action', step) return step.actionProxy.deserialize(data) index = self.buildStepTreeModel.indexByStepPath(stepPath) self.buildStepTreeModel.dataChanged.emit(index, index, []) def getActionAttr(self, attrPath, variantIndex=-1): """ Return the value of an attribute of a BuildAction Args: attrPath (str): The full path to an action attribute, e.g. 'My/Action.myAttr' variantIndex (int): The index of the variant to retrieve, if the action has variants Returns: The attribute value, of varying types """ stepPath, attrName = attrPath.split('.') step = self.getStep(stepPath) if not step: return if not step.isAction(): LOG.error('getActionAttr: %s is not an action', step) return if variantIndex >= 0: if step.actionProxy.numVariants() > variantIndex: actionData = step.actionProxy.getVariant(variantIndex) return actionData.getAttrValue(attrName) else: return step.actionProxy.getAttrValue(attrName) def setActionAttr(self, attrPath, value, variantIndex=-1): """ Set the value for an attribute on the Blueprint """ if self.isReadOnly(): LOG.error('Cannot edit readonly Blueprint') return stepPath, attrName = attrPath.split('.') step = self.getStep(stepPath) if not step: return if not step.isAction(): LOG.error('setActionAttr: %s is not an action', step) return if variantIndex >= 0: variant = step.actionProxy.getOrCreateVariant(variantIndex) variant.setAttrValue(attrName, value) else: step.actionProxy.setAttrValue(attrName, value) index = self.buildStepTreeModel.indexByStepPath(stepPath) self.buildStepTreeModel.dataChanged.emit(index, index, []) def isActionAttrVariant(self, attrPath): stepPath, attrName = attrPath.split('.') step = self.getStep(stepPath) if not step.isAction(): LOG.error( "isActionAttrVariant: {0} is not an action".format(step)) return return step.actionProxy.isVariantAttr(attrName) def setIsActionAttrVariant(self, attrPath, isVariant): """ """ if self.isReadOnly(): LOG.error('Cannot edit readonly Blueprint') return stepPath, attrName = attrPath.split('.') step = self.getStep(stepPath) if not step: return if not step.isAction(): LOG.error( "setIsActionAttrVariant: {0} is not an action".format(step)) return step.actionProxy.setIsVariantAttr(attrName, isVariant) index = self.buildStepTreeModel.indexByStepPath(stepPath) self.buildStepTreeModel.dataChanged.emit(index, index, [])
class BuildItemForm(QtWidgets.QWidget): """ Base class for a form for editing any type of BuildItem """ buildItemChanged = QtCore.Signal() @staticmethod def createItemWidget(buildItem, parent=None): if isinstance(buildItem, pulse.BuildGroup): return BuildGroupForm(buildItem, parent=parent) elif isinstance(buildItem, pulse.BuildAction): return ActionForm(buildItem, parent=parent) elif isinstance(buildItem, pulse.BatchBuildAction): return BatchActionForm(buildItem, parent=parent) return QtWidgets.QWidget(parent=parent) def __init__(self, buildItem, parent=None): super(BuildItemForm, self).__init__(parent=parent) self.buildItem = buildItem self.setupUi(self) self.setupContentUi(self) def getItemDisplayName(self): return self.buildItem.getDisplayName() def getItemIcon(self): iconFile = self.buildItem.getIconFile() if iconFile: return QtGui.QIcon(iconFile) def getItemColor(self): color = self.buildItem.getColor() if color: return [int(c * 255) for c in color] else: return [255, 255, 255] def setupUi(self, parent): """ Create the UI that is common to all BuildItem editors, including a basic header and layout. """ # main layout containing header and body layout = QtWidgets.QVBoxLayout(parent) layout.setSpacing(4) layout.setMargin(0) # header frame self.headerFrame = QtWidgets.QFrame(parent) headerColor = 'rgba({0}, {1}, {2}, 40)'.format(*self.getItemColor()) self.headerFrame.setStyleSheet( ".QFrame{{ background-color: {color}; border-radius: 2px; }}". format(color=headerColor)) layout.addWidget(self.headerFrame) # header layout self.headerLayout = QtWidgets.QHBoxLayout(self.headerFrame) self.headerLayout.setContentsMargins(10, 4, 4, 4) # display name label font = QtGui.QFont() font.setWeight(75) font.setBold(True) self.displayNameLabel = QtWidgets.QLabel(self.headerFrame) self.displayNameLabel.setMinimumHeight(18) self.displayNameLabel.setFont(font) self.displayNameLabel.setText(self.getItemDisplayName()) self.headerLayout.addWidget(self.displayNameLabel) # body layout bodyFrame = QtWidgets.QFrame(parent) bodyFrame.setObjectName("bodyFrame") bodyColor = 'rgba(255, 255, 255, 5)'.format(*self.getItemColor()) bodyFrame.setStyleSheet( ".QFrame#bodyFrame{{ background-color: {color}; }}".format( color=bodyColor)) layout.addWidget(bodyFrame) self.mainLayout = QtWidgets.QVBoxLayout(bodyFrame) self.mainLayout.setMargin(6) self.mainLayout.setSpacing(0) def setupContentUi(self, parent): pass
class BatchActionForm(BuildItemForm): """ Form for editing Batch Actions. Very similar to the standard ActionForm, with a few key differences. Each attribute has a toggle that controls whether the attribute is variant or not. All variants are displayed in a list, with the ability to easily add and remove variants. If a BatchAttrForm exists for a variant attribute type, it will be displayed in place of the normal AttrEditor form (only when that attribute is marked as variant). """ convertToActionClicked = QtCore.Signal() def getItemDisplayName(self): return 'Batch {0} (x{1})'.format(self.buildItem.getDisplayName(), self.buildItem.getActionCount()) def setupUi(self, parent): super(BatchActionForm, self).setupUi(parent) # add action conversion button to header convertToActionBtn = QtWidgets.QPushButton(parent) convertToActionBtn.setIcon( viewutils.getIcon("convertBatchToAction.png")) convertToActionBtn.setFixedSize(QtCore.QSize(18, 18)) convertToActionBtn.clicked.connect(self.convertToActionClicked.emit) self.headerLayout.addWidget(convertToActionBtn) def setupContentUi(self, parent): """ Build the content ui for this BatchBuildAction. Creates ui to manage the array of variant attributes. """ # constants main layout self.constantsLayout = QtWidgets.QVBoxLayout(parent) self.constantsLayout.setContentsMargins(0, 0, 0, 0) self.mainLayout.addLayout(self.constantsLayout) spacer = QtWidgets.QSpacerItem(20, 4, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.mainLayout.addItem(spacer) # variant header variantHeader = QtWidgets.QFrame(parent) variantHeader.setStyleSheet( ".QFrame{ background-color: rgb(255, 255, 255, 15); border-radius: 2px }" ) self.mainLayout.addWidget(variantHeader) variantHeaderLayout = QtWidgets.QHBoxLayout(variantHeader) variantHeaderLayout.setContentsMargins(10, 4, 4, 4) variantHeaderLayout.setSpacing(4) self.variantsLabel = QtWidgets.QLabel(variantHeader) self.variantsLabel.setText("Variants: {0}".format( len(self.buildItem.variantValues))) variantHeaderLayout.addWidget(self.variantsLabel) spacer = QtWidgets.QSpacerItem(20, 4, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.mainLayout.addItem(spacer) # add variant button addVariantBtn = QtWidgets.QPushButton(variantHeader) addVariantBtn.setText('+') addVariantBtn.setFixedSize(QtCore.QSize(20, 20)) addVariantBtn.clicked.connect(self.addVariant) variantHeaderLayout.addWidget(addVariantBtn) # variant list main layout self.variantLayout = QtWidgets.QVBoxLayout(parent) self.variantLayout.setContentsMargins(0, 0, 0, 0) self.variantLayout.setSpacing(4) self.mainLayout.addLayout(self.variantLayout) self.setupConstantsUi(parent) self.setupVariantsUi(parent) def setupConstantsUi(self, parent): viewutils.clearLayout(self.constantsLayout) # create attr form all constant attributes for attr in self.buildItem.actionClass.config['attrs']: isConstant = (attr['name'] in self.buildItem.constantValues) # make an HBox with a button to toggle variant state attrHLayout = QtWidgets.QHBoxLayout(parent) attrHLayout.setSpacing(10) attrHLayout.setMargin(0) self.constantsLayout.addLayout(attrHLayout) if isConstant: # constant value, make an attr form attrValue = self.buildItem.constantValues[attr['name']] context = self.buildItem.constantValues attrForm = ActionAttrForm.createForm(attr, attrValue, parent=parent) attrForm.valueChanged.connect( partial(self.attrValueChanged, context, attrForm)) attrHLayout.addWidget(attrForm) else: # variant value, check for batch editor, or just display a label attrLabel = QtWidgets.QLabel(parent) # extra 2 to account for the left-side frame padding that occurs in the ActionAttrForm attrLabel.setFixedSize( QtCore.QSize(ActionAttrForm.LABEL_WIDTH + 2, ActionAttrForm.LABEL_HEIGHT)) attrLabel.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTrailing | QtCore.Qt.AlignTop) attrLabel.setMargin(2) attrLabel.setText(pulse.names.toTitle(attr['name'])) attrLabel.setEnabled(False) attrHLayout.addWidget(attrLabel) if BatchAttrForm.doesFormExist(attr): batchEditor = BatchAttrForm.createForm(self.buildItem, attr, parent=parent) batchEditor.valuesChanged.connect( self.batchEditorValuesChanged) attrHLayout.addWidget(batchEditor) else: spacer = QtWidgets.QSpacerItem( 24, 24, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) attrHLayout.addItem(spacer) # not a constant value, add a line with button to make it constant # button to toggle variant toggleVariantBtn = QtWidgets.QPushButton(parent) toggleVariantBtn.setText("v") toggleVariantBtn.setFixedSize(QtCore.QSize(20, 20)) toggleVariantBtn.setCheckable(True) toggleVariantBtn.setChecked(not isConstant) attrHLayout.addWidget(toggleVariantBtn) attrHLayout.setAlignment(toggleVariantBtn, QtCore.Qt.AlignTop) toggleVariantBtn.clicked.connect( partial(self.setIsVariantAttr, attr['name'], isConstant)) def setupVariantsUi(self, parent): viewutils.clearLayout(self.variantLayout) self.variantsLabel.setText("Variants: {0}".format( len(self.buildItem.variantValues))) for i, variant in enumerate(self.buildItem.variantValues): if i > 0: # divider line dividerLine = QtWidgets.QFrame(parent) dividerLine.setStyleSheet( ".QFrame{ background-color: rgb(0, 0, 0, 15); border-radius: 2px }" ) dividerLine.setMinimumHeight(2) self.variantLayout.addWidget(dividerLine) variantHLayout = QtWidgets.QHBoxLayout(parent) # remove variant button removeVariantBtn = QtWidgets.QPushButton(parent) removeVariantBtn.setText('x') removeVariantBtn.setFixedSize(QtCore.QSize(20, 20)) removeVariantBtn.clicked.connect( partial(self.removeVariantAtIndex, i)) variantHLayout.addWidget(removeVariantBtn) # create attr form for all variant attributes variantVLayout = QtWidgets.QVBoxLayout(parent) variantVLayout.setSpacing(0) variantHLayout.addLayout(variantVLayout) if self.buildItem.variantAttributes: for attr in self.buildItem.actionClass.config['attrs']: if attr['name'] not in self.buildItem.variantAttributes: continue attrValue = variant[attr['name']] # context = variant attrForm = ActionAttrForm.createForm(attr, attrValue, parent=parent) attrForm.valueChanged.connect( partial(self.attrValueChanged, variant, attrForm)) variantVLayout.addWidget(attrForm) else: noAttrsLabel = QtWidgets.QLabel(parent) noAttrsLabel.setText("No variant attributes") noAttrsLabel.setMinimumHeight(24) noAttrsLabel.setContentsMargins(10, 0, 0, 0) noAttrsLabel.setEnabled(False) variantVLayout.addWidget(noAttrsLabel) self.variantLayout.addLayout(variantHLayout) def setIsVariantAttr(self, attrName, isVariant): if isVariant: self.buildItem.addVariantAttr(attrName) else: self.buildItem.removeVariantAttr(attrName) self.setupConstantsUi(self) self.setupVariantsUi(self) def addVariant(self): self.buildItem.addVariant() self.setupVariantsUi(self) def removeVariantAtIndex(self, index): self.buildItem.removeVariantAt(index) self.setupVariantsUi(self) def removeVariantFromEnd(self): self.buildItem.removeVariantAt(-1) self.setupVariantsUi(self) def attrValueChanged(self, context, attrForm, attrValue, isValueValid): """ Args: context: A dict representing the either constantValues object or a variant within the batch action """ attrName = attrForm.attr['name'] # prevent adding new keys to the context dict if attrName in context: context[attrName] = attrValue self.buildItemChanged.emit() def batchEditorValuesChanged(self): self.setupVariantsUi(self)
class ActionAttrForm(QtWidgets.QWidget): """ The base class for all forms used to edit action attributes. Provides input validation and basic signals for keeping track of value changes. """ TYPEMAP = {} LABEL_WIDTH = 150 LABEL_HEIGHT = 20 FORM_WIDTH_SMALL = 80 # valueChanged(newValue, isValueValid) valueChanged = QtCore.Signal(object, bool) @staticmethod def createForm(attr, attrValue, parent=None): """ Create a new ActionAttrForm of the appropriate type based on a BuildAction attribute. Args: attr: A dict representing the config of a BuildAction attribute attrValue: The current value of the attribute """ attrType = attr['type'] if attrType in ActionAttrForm.TYPEMAP: return ActionAttrForm.TYPEMAP[attrType](attr, attrValue, parent=parent) # fallback to the default widget return DefaultAttrForm(attr, attrValue) def __init__(self, attr, attrValue, parent=None): super(ActionAttrForm, self).__init__(parent=parent) # the config data of the attribute being edited self.attr = attr # the current value of the attribute self.attrValue = attrValue # build the ui self.setupUi(self) # update valid state, check both type and value here # because the current value may be of an invalid type self.isValueValid = self._isValueTypeValid( self.attrValue) and self._isValueValid(self.attrValue) self._setUiValidState(self.isValueValid) def setAttrValue(self, newValue): """ Set the current value of the attribute in this form. Performs partial validation and prevents setting the value if it's type is invalid. """ # value doesn't need to be valid as long # as it has the right type if self._isValueTypeValid(newValue): self.attrValue = newValue self._setFormValue(newValue) self.isValueValid = self._isValueValid(newValue) self._setUiValidState(self.isValueValid) return True else: return False def setupUi(self, parent): """ Build the appropriate ui for the attribute """ raise NotImplementedError def _setFormValue(self, attrValue): """ Set the current value displayed in the UI form """ raise NotImplementedError def _getFormValue(self): """ Return the current attribute value from the UI form. The result must always be of a valid type for this attr, though the value itself can be invalid. """ raise NotImplementedError def _isFormValid(self): """ Return True if the current form contains valid data. """ return True def _isValueTypeValid(self, attrValue): """ Return True if a potential value for the attribute matches the type of attribute. Attributes of at least a valid type can be saved, even though they may cause issues if not fixed before building. """ return True def _isValueValid(self, attrValue): """ Return True if a potential value for the attribute is valid """ return True def _valueChanged(self): """ Update the current attrValue and isValueValid state. Should be called whenever relevant UI values change. The new value will be retrieved by using `_getFormValue`, and validated using `_isValueValid` """ # only emit when form is valid if self._isFormValid(): self.attrValue = self._getFormValue() self.isValueValid = self._isValueValid(self.attrValue) self._setUiValidState(self.isValueValid) self.valueChanged.emit(self.attrValue, self.isValueValid) else: self._setUiValidState(False) def _setUiValidState(self, isValid): if hasattr(self, 'frame'): if isValid: self.frame.setStyleSheet('') else: self.frame.setStyleSheet( '.QFrame{ background-color: rgb(255, 0, 0, 35); }') def setupDefaultFormUi(self, parent): """ Optional UI setup that builds a standardized layout. Includes a form layout and a label with the attributes name. Should be called at the start of setupUi if desired. """ layout = QtWidgets.QVBoxLayout(parent) layout.setContentsMargins(0, 0, 0, 0) self.frame = QtWidgets.QFrame(parent) layout.addWidget(self.frame) self.formLayout = QtWidgets.QFormLayout(self.frame) # margin that will give us some visible area of # the frame that can change color based on valid state self.formLayout.setContentsMargins(2, 2, 2, 2) self.formLayout.setFieldGrowthPolicy( QtWidgets.QFormLayout.ExpandingFieldsGrow) self.formLayout.setLabelAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTop | QtCore.Qt.AlignTrailing) self.formLayout.setHorizontalSpacing(10) # attribute name self.label = QtWidgets.QLabel(self.frame) self.label.setMinimumSize( QtCore.QSize(self.LABEL_WIDTH, self.LABEL_HEIGHT)) self.label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTrailing | QtCore.Qt.AlignTop) # add some space above the label so it lines up self.label.setMargin(2) self.label.setText(pulse.names.toTitle(self.attr['name'])) self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label) def setDefaultFormWidget(self, widget): """ Set the widget to be used as the field in the default form layout Requires `setupDefaultFormUi` to be used. """ self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, widget) def setDefaultFormLayout(self, layout): """ Set the layout to be used as the field in the default form layout. Requires `setupDefaultFormUi` to be used. """ self.formLayout.setLayout(0, QtWidgets.QFormLayout.FieldRole, layout)