diff options
Diffstat (limited to 'src/component.py')
| -rw-r--r-- | src/component.py | 461 |
1 files changed, 377 insertions, 84 deletions
diff --git a/src/component.py b/src/component.py index cf3085c..f3ee188 100644 --- a/src/component.py +++ b/src/component.py @@ -9,10 +9,11 @@ import sys import math import time import logging +from copy import copy from toolkit.frame import BlankFrame from toolkit import ( - getWidgetValue, setWidgetValue, connectWidget, rgbFromString + getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals ) @@ -39,11 +40,10 @@ class ComponentMetaclass(type(QtCore.QObject)): def renderWrapper(func): def renderWrapper(self, *args, **kwargs): try: - log.verbose('### %s #%s renders%s frame %s###' % ( + log.verbose( + '### %s #%s renders a preview frame ###', self.__class__.name, str(self.compPos), - '' if args else ' a preview', - '' if not args else '%s ' % args[0], - )) + ) return func(self, *args, **kwargs) except Exception as e: try: @@ -59,9 +59,8 @@ class ComponentMetaclass(type(QtCore.QObject)): '''Intercepts the command() method to check for global args''' def commandWrapper(self, arg): if arg.startswith('preset='): - from presetmanager import getPresetDir _, preset = arg.split('=', 1) - path = os.path.join(getPresetDir(self), preset) + path = os.path.join(self.core.getPresetDir(self), preset) if not os.path.exists(path): print('Couldn\'t locate preset "%s"' % preset) quit(1) @@ -100,6 +99,92 @@ class ComponentMetaclass(type(QtCore.QObject)): return func(self) return errorWrapper + def loadPresetWrapper(func): + '''Wraps loadPreset to handle the self.openingPreset boolean''' + class openingPreset: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + self.comp.openingPreset = True + + def __exit__(self, *args): + self.comp.openingPreset = False + + def presetWrapper(self, *args): + with openingPreset(self): + try: + return func(self, *args) + except Exception: + try: + raise ComponentError(self, 'preset loader') + except ComponentError: + return + return presetWrapper + + def updateWrapper(func): + ''' + Calls _preUpdate before every subclass update(). + Afterwards, for non-user updates, calls _autoUpdate(). + For undoable updates triggered by the user, calls _userUpdate() + ''' + class wrap: + def __init__(self, comp, auto): + self.comp = comp + self.auto = auto + + def __enter__(self): + self.comp._preUpdate() + + def __exit__(self, *args): + if self.auto or self.comp.openingPreset \ + or not hasattr(self.comp.parent, 'undoStack'): + log.verbose('Automatic update') + self.comp._autoUpdate() + else: + log.verbose('User update') + self.comp._userUpdate() + + def updateWrapper(self, **kwargs): + auto = kwargs['auto'] if 'auto' in kwargs else False + with wrap(self, auto): + try: + return func(self) + except Exception: + try: + raise ComponentError(self, 'update method') + except ComponentError: + return + return updateWrapper + + def widgetWrapper(func): + '''Connects all widgets to update method after the subclass's method''' + class wrap: + def __init__(self, comp): + self.comp = comp + + def __enter__(self): + pass + + def __exit__(self, *args): + for widgetList in self.comp._allWidgets.values(): + for widget in widgetList: + log.verbose('Connecting %s', str( + widget.__class__.__name__)) + connectWidget(widget, self.comp.update) + + def widgetWrapper(self, *args, **kwargs): + auto = kwargs['auto'] if 'auto' in kwargs else False + with wrap(self): + try: + return func(self, *args, **kwargs) + except Exception: + try: + raise ComponentError(self, 'widget creation') + except ComponentError: + return + return widgetWrapper + def __new__(cls, name, parents, attrs): if 'ui' not in attrs: # Use module name as ui filename by default @@ -107,54 +192,55 @@ class ComponentMetaclass(type(QtCore.QObject)): attrs['__module__'].split('.')[-1] )[0] - # if parents[0] == QtCore.QObject: else: decorate = ( 'names', # Class methods 'error', 'audio', 'properties', # Properties 'preFrameRender', 'previewRender', - 'frameRender', 'command', + 'loadPreset', 'command', + 'update', 'widget', ) # Auto-decorate methods for key in decorate: if key not in attrs: continue - if key in ('names'): attrs[key] = classmethod(attrs[key]) - - if key in ('audio'): + elif key in ('audio'): attrs[key] = property(attrs[key]) - - if key == 'command': + elif key == 'command': attrs[key] = cls.commandWrapper(attrs[key]) - - if key in ('previewRender', 'frameRender'): + elif key == 'previewRender': attrs[key] = cls.renderWrapper(attrs[key]) - - if key == 'preFrameRender': + elif key == 'preFrameRender': attrs[key] = cls.initializationWrapper(attrs[key]) - - if key == 'properties': + elif key == 'properties': attrs[key] = cls.propertiesWrapper(attrs[key]) - - if key == 'error': + elif key == 'error': attrs[key] = cls.errorWrapper(attrs[key]) + elif key == 'loadPreset': + attrs[key] = cls.loadPresetWrapper(attrs[key]) + elif key == 'update': + attrs[key] = cls.updateWrapper(attrs[key]) + elif key == 'widget' and parents[0] != QtCore.QObject: + attrs[key] = cls.widgetWrapper(attrs[key]) # Turn version string into a number try: if 'version' not in attrs: log.error( - 'No version attribute in %s. Defaulting to 1' % + 'No version attribute in %s. Defaulting to 1', attrs['name']) attrs['version'] = 1 else: attrs['version'] = int(attrs['version'].split('.')[0]) except ValueError: - log.critical('%s component has an invalid version string:\n%s' % ( - attrs['name'], str(attrs['version']))) + log.critical( + '%s component has an invalid version string:\n%s', + attrs['name'], str(attrs['version']) + ) except KeyError: - log.critical('%s component has no version string.' % attrs['name']) + log.critical('%s component has no version string.', attrs['name']) else: return super().__new__(cls, name, parents, attrs) quit(1) @@ -180,23 +266,29 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self.moduleIndex = moduleIndex self.compPos = compPos self.core = core - self.currentPreset = None + # STATUS VARIABLES + self.currentPreset = None + self._allWidgets = {} self._trackedWidgets = {} self._presetNames = {} self._commandArgs = {} self._colorWidgets = {} self._colorFuncs = {} self._relativeWidgets = {} - # pixel values stored as floats + # Pixel values stored as floats self._relativeValues = {} - # maximum values of spinBoxes at 1080p (Core.resolutions[0]) + # Maximum values of spinBoxes at 1080p (Core.resolutions[0]) self._relativeMaximums = {} + # LOCKING VARIABLES + self.openingPreset = False + self.mergeUndo = True self._lockedProperties = None self._lockedError = None self._lockedSize = None - + # If set to a dict, values are used as basis to update relative widgets + self.oldAttrs = None # Stop lengthy processes in response to this variable self.canceled = False @@ -204,12 +296,20 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): return self.__class__.name def __repr__(self): + import pprint try: preset = self.savePreset() except Exception as e: preset = '%s occurred while saving preset' % str(e) - return '%s\n%s\n%s' % ( - self.__class__.name, str(self.__class__.version), preset + + return ( + 'Component(module %s, pos %s) (%s)\n' + 'Name: %s v%s\nPreset: %s' % ( + self.moduleIndex, self.compPos, + object.__repr__(self), + self.__class__.name, str(self.__class__.version), + pprint.pformat(preset) + ) ) # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ @@ -288,54 +388,29 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): ''' self.parent = parent self.settings = parent.settings + log.verbose( + 'Creating UI for %s #%s\'s widget', + self.__class__.name, self.compPos + ) self.page = self.loadUi(self.__class__.ui) - # Connect widget signals - widgets = { + # Find all normal widgets which will be connected after subclass method + self._allWidgets = { 'lineEdit': self.page.findChildren(QtWidgets.QLineEdit), 'checkBox': self.page.findChildren(QtWidgets.QCheckBox), 'spinBox': self.page.findChildren(QtWidgets.QSpinBox), 'comboBox': self.page.findChildren(QtWidgets.QComboBox), } - widgets['spinBox'].extend( + self._allWidgets['spinBox'].extend( self.page.findChildren(QtWidgets.QDoubleSpinBox) ) - for widgetList in widgets.values(): - for widget in widgetList: - connectWidget(widget, self.update) def update(self): ''' - Reads all tracked widget values into instance attributes - and tells the MainWindow that the component was modified. - Call super() at the END if you need to subclass this. + Starting point for a component update. A subclass should override + this method, and the base class will then magically insert a call + to either _autoUpdate() or _userUpdate() at the end. ''' - for attr, widget in self._trackedWidgets.items(): - if attr in self._colorWidgets: - # Color Widgets: text stored as tuple & update the button color - rgbTuple = rgbFromString(widget.text()) - btnStyle = ( - "QPushButton { background-color : %s; outline: none; }" - % QColor(*rgbTuple).name()) - self._colorWidgets[attr].setStyleSheet(btnStyle) - setattr(self, attr, rgbTuple) - - elif attr in self._relativeWidgets: - # Relative widgets: number scales to fit export resolution - self.updateRelativeWidget(attr) - setattr(self, attr, self._trackedWidgets[attr].value()) - - else: - # Normal tracked widget - setattr(self, attr, getWidgetValue(widget)) - self.sendUpdateSignal() - - def sendUpdateSignal(self): - if not self.core.openingProject: - self.parent.drawPreview() - saveValueStore = self.savePreset() - saveValueStore['preset'] = self.currentPreset - self.modified.emit(self.compPos, saveValueStore) def loadPreset(self, presetDict, presetName=None): ''' @@ -348,7 +423,14 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): for attr, widget in self._trackedWidgets.items(): key = attr if attr not in self._presetNames \ else self._presetNames[attr] - val = presetDict[key] + try: + val = presetDict[key] + except KeyError as e: + log.info( + '%s missing value %s. Outdated preset?', + self.currentPreset, str(e) + ) + val = getattr(self, key) if attr in self._colorWidgets: widget.setText('%s,%s,%s' % val) @@ -403,6 +485,85 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ # "Private" Methods # =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~ + def _preUpdate(self): + '''Happens before subclass update()''' + for attr in self._relativeWidgets: + self.updateRelativeWidget(attr) + + def _userUpdate(self): + '''Happens after subclass update() for an undoable update by user.''' + oldWidgetVals = { + attr: copy(getattr(self, attr)) + for attr in self._trackedWidgets + } + newWidgetVals = { + attr: getWidgetValue(widget) + if attr not in self._colorWidgets else rgbFromString(widget.text()) + for attr, widget in self._trackedWidgets.items() + } + modifiedWidgets = { + attr: val + for attr, val in newWidgetVals.items() + if val != oldWidgetVals[attr] + } + if modifiedWidgets: + action = ComponentUpdate(self, oldWidgetVals, modifiedWidgets) + self.parent.undoStack.push(action) + + def _autoUpdate(self): + '''Happens after subclass update() for an internal component update.''' + newWidgetVals = { + attr: getWidgetValue(widget) + for attr, widget in self._trackedWidgets.items() + } + self.setAttrs(newWidgetVals) + self._sendUpdateSignal() + + def setAttrs(self, attrDict): + ''' + Sets attrs (linked to trackedWidgets) in this component to + the values in the attrDict. Mutates certain widget values if needed + ''' + for attr, val in attrDict.items(): + if attr in self._colorWidgets: + # Color Widgets must have a tuple & have a button to update + if type(val) is tuple: + rgbTuple = val + else: + rgbTuple = rgbFromString(val) + btnStyle = ( + "QPushButton { background-color : %s; outline: none; }" + % QColor(*rgbTuple).name()) + self._colorWidgets[attr].setStyleSheet(btnStyle) + setattr(self, attr, rgbTuple) + + else: + # Normal tracked widget + setattr(self, attr, val) + log.verbose('Setting %s self.%s to %s' % ( + self.__class__.name, attr, val)) + + def setWidgetValues(self, attrDict): + ''' + Sets widgets defined by keys in trackedWidgets in this preset to + the values in the attrDict. + ''' + affectedWidgets = [ + self._trackedWidgets[attr] for attr in attrDict + ] + with blockSignals(affectedWidgets): + for attr, val in attrDict.items(): + widget = self._trackedWidgets[attr] + if attr in self._colorWidgets: + val = '%s,%s,%s' % val + setWidgetValue(widget, val) + + def _sendUpdateSignal(self): + if not self.core.openingProject: + self.parent.drawPreview() + saveValueStore = self.savePreset() + saveValueStore['preset'] = self.currentPreset + self.modified.emit(self.compPos, saveValueStore) def trackWidgets(self, trackDict, **kwargs): ''' @@ -412,6 +573,8 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): Optional args: 'presetNames': preset variable names to replace attr names 'commandArgs': arg keywords that differ from attr names + 'colorWidgets': identify attr as RGB tuple & update button CSS + 'relativeWidgets': change value proportionally to resolution NOTE: Any kwarg key set to None will selectively disable tracking. ''' @@ -424,7 +587,7 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): 'colorWidgets', 'relativeWidgets', ): - setattr(self, '_%s' % kwarg, kwargs[kwarg]) + setattr(self, '_{}'.format(kwarg), kwargs[kwarg]) else: raise ComponentError( self, 'Nonsensical keywords to trackWidgets.') @@ -434,10 +597,12 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): if kwarg == 'colorWidgets': def makeColorFunc(attr): def pickColor_(): + self.mergeUndo = False self.pickColor( self._trackedWidgets[attr], self._colorWidgets[attr] ) + self.mergeUndo = True return pickColor_ self._colorFuncs = { attr: makeColorFunc(attr) for attr in kwargs[kwarg] @@ -455,6 +620,12 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): self._relativeMaximums[attr] = \ self._trackedWidgets[attr].maximum() self.updateRelativeWidgetMaximum(attr) + setattr( + self, attr, getWidgetValue(self._trackedWidgets[attr]) + ) + + self._preUpdate() + self._autoUpdate() def pickColor(self, textWidget, button): '''Use color picker to get color input from the user.''' @@ -516,12 +687,22 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def relativeWidgetAxis(func): def relativeWidgetAxis(self, attr, *args, **kwargs): + hasVerticalWords = ( + lambda attr: + 'height' in attr.lower() or + 'ypos' in attr.lower() or + attr == 'y' + ) if 'axis' not in kwargs: axis = self.width - if 'height' in attr.lower() \ - or 'ypos' in attr.lower() or attr == 'y': + if hasVerticalWords(attr): axis = self.height kwargs['axis'] = axis + if 'axis' in kwargs and type(kwargs['axis']) is tuple: + axis = kwargs['axis'][0] + if hasVerticalWords(attr): + axis = kwargs['axis'][1] + kwargs['axis'] = axis return func(self, attr, *args, **kwargs) return relativeWidgetAxis @@ -529,7 +710,18 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def pixelValForAttr(self, attr, val=None, **kwargs): if val is None: val = self._relativeValues[attr] - return math.ceil(kwargs['axis'] * val) + if val > 50.0: + log.warning( + '%s #%s attempted to set %s to dangerously high number %s', + self.__class__.name, self.compPos, attr, val + ) + val = 50.0 + result = math.ceil(kwargs['axis'] * val) + log.verbose( + 'Converting %s: f%s to px%s using axis %s', + attr, val, result, kwargs['axis'] + ) + return result @relativeWidgetAxis def floatValForAttr(self, attr, val=None, **kwargs): @@ -540,14 +732,28 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): def setRelativeWidget(self, attr, floatVal): '''Set a relative widget using a float''' pixelVal = self.pixelValForAttr(attr, floatVal) - self._trackedWidgets[attr].setValue(pixelVal) + with blockSignals(self._trackedWidgets[attr]): + self._trackedWidgets[attr].setValue(pixelVal) + self.update(auto=True) + def getOldAttr(self, attr): + ''' + Returns previous state of this attr. Used to determine whether + a relative widget must be updated. Required because undoing/redoing + can make determining the 'previous' value tricky. + ''' + if self.oldAttrs is not None: + return self.oldAttrs[attr] + else: + try: + return getattr(self, attr) + except AttributeError: + log.error('Using visible values instead of oldAttrs') + return self._trackedWidgets[attr].value() def updateRelativeWidget(self, attr): - try: - oldUserValue = getattr(self, attr) - except AttributeError: - oldUserValue = self._trackedWidgets[attr].value() + '''Called by _preUpdate() for each relativeWidget before each update''' + oldUserValue = self.getOldAttr(attr) newUserValue = self._trackedWidgets[attr].value() newRelativeVal = self.floatValForAttr(attr, newUserValue) @@ -557,13 +763,13 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): and oldRelativeVal != newRelativeVal: # Float changed without pixel value changing, which # means the pixel value needs to be updated - log.debug('Updating %s #%s\'s relative widget: %s' % ( - self.name, self.compPos, attr)) - self._trackedWidgets[attr].blockSignals(True) - self.updateRelativeWidgetMaximum(attr) - pixelVal = self.pixelValForAttr(attr, oldRelativeVal) - self._trackedWidgets[attr].setValue(pixelVal) - self._trackedWidgets[attr].blockSignals(False) + log.debug( + 'Updating %s #%s\'s relative widget: %s', + self.__class__.name, self.compPos, attr) + with blockSignals(self._trackedWidgets[attr]): + self.updateRelativeWidgetMaximum(attr) + pixelVal = self.pixelValForAttr(attr, oldRelativeVal) + self._trackedWidgets[attr].setValue(pixelVal) if attr not in self._relativeValues \ or oldUserValue != newUserValue: @@ -629,3 +835,90 @@ class ComponentError(RuntimeError): super().__init__(string) caller.lockError(string) caller._error.emit(string, detail) + + +class ComponentUpdate(QtWidgets.QUndoCommand): + '''Command object for making a component action undoable''' + def __init__(self, parent, oldWidgetVals, modifiedVals): + super().__init__( + 'change %s component #%s' % ( + parent.name, parent.compPos + ) + ) + self.undone = False + self.res = (int(parent.width), int(parent.height)) + self.parent = parent + self.oldWidgetVals = { + attr: copy(val) + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + for attr, val in oldWidgetVals.items() + if attr in modifiedVals + } + self.modifiedVals = { + attr: val + if attr not in self.parent._relativeWidgets + else self.parent.floatValForAttr(attr, val, axis=self.res) + for attr, val in modifiedVals.items() + } + + # Because relative widgets change themselves every update based on + # their previous value, we must store ALL their values in case of undo + self.relativeWidgetValsAfterUndo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + + # Determine if this update is mergeable + self.id_ = -1 + if len(self.modifiedVals) == 1 and self.parent.mergeUndo: + attr, val = self.modifiedVals.popitem() + self.id_ = sum([ord(letter) for letter in attr[-14:]]) + self.modifiedVals[attr] = val + else: + log.warning( + '%s component settings changed at once. (%s)', + len(self.modifiedVals), repr(self.modifiedVals) + ) + + def id(self): + '''If 2 consecutive updates have same id, Qt will call mergeWith()''' + return self.id_ + + def mergeWith(self, other): + self.modifiedVals.update(other.modifiedVals) + return True + + def setWidgetValues(self, attrDict): + ''' + Mask the component's usual method to handle our + relative widgets in case the resolution has changed. + ''' + newAttrDict = { + attr: val if attr not in self.parent._relativeWidgets + else self.parent.pixelValForAttr(attr, val) + for attr, val in attrDict.items() + } + self.parent.setWidgetValues(newAttrDict) + + def redo(self): + if self.undone: + log.info('Redoing component update') + self.parent.oldAttrs = self.relativeWidgetValsAfterUndo + self.setWidgetValues(self.modifiedVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None + if not self.undone: + self.relativeWidgetValsAfterRedo = { + attr: copy(getattr(self.parent, attr)) + for attr in self.parent._relativeWidgets + } + self.parent._sendUpdateSignal() + + def undo(self): + log.info('Undoing component update') + self.undone = True + self.parent.oldAttrs = self.relativeWidgetValsAfterRedo + self.setWidgetValues(self.oldWidgetVals) + self.parent.update(auto=True) + self.parent.oldAttrs = None |
