This repository has been archived on 2020-08-22. You can view files and clone it, but cannot push or open issues or pull requests.
pyaudviz/src/component.py

925 lines
34 KiB
Python

'''
Base classes for components to import. Read comments for some documentation
on making a valid component.
'''
from PyQt5 import uic, QtCore, QtWidgets
from PyQt5.QtGui import QColor
import os
import sys
import math
import time
import logging
from copy import copy
from toolkit.frame import BlankFrame
from toolkit import (
getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals
)
log = logging.getLogger('AVP.ComponentHandler')
class ComponentMetaclass(type(QtCore.QObject)):
'''
Checks the validity of each Component class and mutates some attrs.
E.g., takes only major version from version string & decorates methods
'''
def initializationWrapper(func):
def initializationWrapper(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
except Exception:
try:
raise ComponentError(self, 'initialization process')
except ComponentError:
return
return initializationWrapper
def renderWrapper(func):
def renderWrapper(self, *args, **kwargs):
try:
log.verbose(
'### %s #%s renders a preview frame ###',
self.__class__.name, str(self.compPos),
)
return func(self, *args, **kwargs)
except Exception as e:
try:
if e.__class__.__name__.startswith('Component'):
raise
else:
raise ComponentError(self, 'renderer')
except ComponentError:
return BlankFrame()
return renderWrapper
def commandWrapper(func):
'''Intercepts the command() method to check for global args'''
def commandWrapper(self, arg):
if arg.startswith('preset='):
_, preset = arg.split('=', 1)
path = os.path.join(self.core.getPresetDir(self), preset)
if not os.path.exists(path):
print('Couldn\'t locate preset "%s"' % preset)
quit(1)
else:
print('Opening "%s" preset on layer %s' % (
preset, self.compPos)
)
self.core.openPreset(path, self.compPos, preset)
# Don't call the component's command() method
return
else:
return func(self, arg)
return commandWrapper
def propertiesWrapper(func):
'''Intercepts the usual properties if the properties are locked.'''
def propertiesWrapper(self):
if self._lockedProperties is not None:
return self._lockedProperties
else:
try:
return func(self)
except Exception:
try:
raise ComponentError(self, 'properties')
except ComponentError:
return []
return propertiesWrapper
def errorWrapper(func):
'''Intercepts the usual error message if it is locked.'''
def errorWrapper(self):
if self._lockedError is not None:
return self._lockedError
else:
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
attrs['ui'] = '%s.ui' % os.path.splitext(
attrs['__module__'].split('.')[-1]
)[0]
decorate = (
'names', # Class methods
'error', 'audio', 'properties', # Properties
'preFrameRender', 'previewRender',
'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])
elif key in ('audio'):
attrs[key] = property(attrs[key])
elif key == 'command':
attrs[key] = cls.commandWrapper(attrs[key])
elif key == 'previewRender':
attrs[key] = cls.renderWrapper(attrs[key])
elif key == 'preFrameRender':
attrs[key] = cls.initializationWrapper(attrs[key])
elif key == 'properties':
attrs[key] = cls.propertiesWrapper(attrs[key])
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',
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'])
)
except KeyError:
log.critical('%s component has no version string.', attrs['name'])
else:
return super().__new__(cls, name, parents, attrs)
quit(1)
class Component(QtCore.QObject, metaclass=ComponentMetaclass):
'''
The base class for components to inherit.
'''
name = 'Component'
# ui = 'name_Of_Non_Default_Ui_File'
version = '1.0.0'
# The major version (before the first dot) is used to determine
# preset compatibility; the rest is ignored so it can be non-numeric.
modified = QtCore.pyqtSignal(int, dict)
_error = QtCore.pyqtSignal(str, str)
def __init__(self, moduleIndex, compPos, core):
super().__init__()
self.moduleIndex = moduleIndex
self.compPos = compPos
self.core = core
# STATUS VARIABLES
self.currentPreset = None
self._allWidgets = {}
self._trackedWidgets = {}
self._presetNames = {}
self._commandArgs = {}
self._colorWidgets = {}
self._colorFuncs = {}
self._relativeWidgets = {}
# Pixel values stored as floats
self._relativeValues = {}
# 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
def __str__(self):
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 (
'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)
)
)
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Render Methods
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
def previewRender(self):
image = BlankFrame(self.width, self.height)
return image
def preFrameRender(self, **kwargs):
'''
Must call super() when subclassing
Triggered only before a video is exported (video_thread.py)
self.audioFile = filepath to the main input audio file
self.completeAudioArray = a list of audio samples
self.sampleSize = number of audio samples per video frame
self.progressBarUpdate = signal to set progress bar number
self.progressBarSetText = signal to set progress bar text
Use the latter two signals to update the MainWindow if needed
for a long initialization procedure (i.e., for a visualizer)
'''
for key, value in kwargs.items():
setattr(self, key, value)
def frameRender(self, frameNo):
audioArrayIndex = frameNo * self.sampleSize
image = BlankFrame(self.width, self.height)
return image
def postFrameRender(self):
pass
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Properties
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
def properties(self):
'''
Return a list of properties to signify if your component is
non-animated ('static'), returns sound ('audio'), or has
encountered an error in configuration ('error').
'''
return []
def error(self):
'''
Return a string containing an error message, or None for a default.
Or tuple of two strings for a message with details.
Alternatively use lockError(msgString) within properties()
to skip this method entirely.
'''
return
def audio(self):
'''
Return audio to mix into master as a tuple with two elements:
The first element can be:
- A string (path to audio file),
- Or an object that returns audio data through a pipe
The second element must be a dictionary of ffmpeg filters/options
to apply to the input stream. See the filter docs for ideas:
https://ffmpeg.org/ffmpeg-filters.html
'''
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
# Idle Methods
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
def widget(self, parent):
'''
Call super().widget(*args) to create the component widget
which also auto-connects any common widgets (e.g., checkBoxes)
to self.update(). Then in a subclass connect special actions
(e.g., pushButtons to select a file) and initialize
'''
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)
# 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),
}
self._allWidgets['spinBox'].extend(
self.page.findChildren(QtWidgets.QDoubleSpinBox)
)
def update(self):
'''
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.
'''
def loadPreset(self, presetDict, presetName=None):
'''
Subclasses should take (presetDict, *args) as args.
Must use super().loadPreset(presetDict, *args) first,
then update self.page widgets using the preset dict.
'''
self.currentPreset = presetName \
if presetName is not None else presetDict['preset']
for attr, widget in self._trackedWidgets.items():
key = attr if attr not in self._presetNames \
else self._presetNames[attr]
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)
btnStyle = (
"QPushButton { background-color : %s; outline: none; }"
% QColor(*val).name()
)
self._colorWidgets[attr].setStyleSheet(btnStyle)
elif attr in self._relativeWidgets:
self._relativeValues[attr] = val
pixelVal = self.pixelValForAttr(attr, val)
setWidgetValue(widget, pixelVal)
else:
setWidgetValue(widget, val)
def savePreset(self):
saveValueStore = {}
for attr, widget in self._trackedWidgets.items():
presetAttrName = (
attr if attr not in self._presetNames
else self._presetNames[attr]
)
if attr in self._relativeWidgets:
try:
val = self._relativeValues[attr]
except AttributeError:
val = self.floatValForAttr(attr)
else:
val = getattr(self, attr)
saveValueStore[presetAttrName] = val
return saveValueStore
def commandHelp(self):
'''Help text as string for this component's commandline arguments'''
def command(self, arg=''):
'''
Configure a component using an arg from the commandline. This is
never called if global args like 'preset=' are found in the arg.
So simply check for any non-global args in your component and
call super().command() at the end to get a Help message.
'''
print(
self.__class__.name, 'Usage:\n'
'Open a preset for this component:\n'
' "preset=Preset Name"'
)
self.commandHelp()
quit(0)
# =~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~=~==~=~=~=~=~=~=~=~=~=~=~=~=~=~
# "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):
'''
Name widgets to track in update(), savePreset(), loadPreset(), and
command(). Requires a dict of attr names as keys, widgets as values
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.
'''
self._trackedWidgets = trackDict
for kwarg in kwargs:
try:
if kwarg in (
'presetNames',
'commandArgs',
'colorWidgets',
'relativeWidgets',
):
setattr(self, '_{}'.format(kwarg), kwargs[kwarg])
else:
raise ComponentError(
self, 'Nonsensical keywords to trackWidgets.')
except ComponentError:
continue
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]
}
for attr, func in self._colorFuncs.items():
self._colorWidgets[attr].clicked.connect(func)
self._colorWidgets[attr].setStyleSheet(
"QPushButton {"
"background-color : #FFFFFF; outline: none; }"
)
if kwarg == 'relativeWidgets':
# store maximum values of spinBoxes to be scaled appropriately
for attr in kwargs[kwarg]:
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.'''
dialog = QtWidgets.QColorDialog()
dialog.setOption(QtWidgets.QColorDialog.ShowAlphaChannel, True)
color = dialog.getColor()
if color.isValid():
RGBstring = '%s,%s,%s' % (
str(color.red()), str(color.green()), str(color.blue()))
btnStyle = "QPushButton{background-color: %s; outline: none;}" \
% color.name()
textWidget.setText(RGBstring)
button.setStyleSheet(btnStyle)
def lockProperties(self, propList):
self._lockedProperties = propList
def lockError(self, msg):
self._lockedError = msg
def lockSize(self, w, h):
self._lockedSize = (w, h)
def unlockProperties(self):
self._lockedProperties = None
def unlockError(self):
self._lockedError = None
def unlockSize(self):
self._lockedSize = None
def loadUi(self, filename):
'''Load a Qt Designer ui file to use for this component's widget'''
return uic.loadUi(os.path.join(self.core.componentsPath, filename))
@property
def width(self):
if self._lockedSize is None:
return int(self.settings.value('outputWidth'))
else:
return self._lockedSize[0]
@property
def height(self):
if self._lockedSize is None:
return int(self.settings.value('outputHeight'))
else:
return self._lockedSize[1]
def cancel(self):
'''Stop any lengthy process in response to this variable.'''
self.canceled = True
def reset(self):
self.canceled = False
self.unlockProperties()
self.unlockError()
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 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
@relativeWidgetAxis
def pixelValForAttr(self, attr, val=None, **kwargs):
if val is None:
val = self._relativeValues[attr]
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):
if val is None:
val = self._trackedWidgets[attr].value()
return val / kwargs['axis']
def setRelativeWidget(self, attr, floatVal):
'''Set a relative widget using a float'''
pixelVal = self.pixelValForAttr(attr, floatVal)
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):
'''Called by _preUpdate() for each relativeWidget before each update'''
oldUserValue = self.getOldAttr(attr)
newUserValue = self._trackedWidgets[attr].value()
newRelativeVal = self.floatValForAttr(attr, newUserValue)
if attr in self._relativeValues:
oldRelativeVal = self._relativeValues[attr]
if oldUserValue == newUserValue \
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.__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:
self._relativeValues[attr] = newRelativeVal
def updateRelativeWidgetMaximum(self, attr):
maxRes = int(self.core.resolutions[0].split('x')[0])
newMaximumValue = self.width * (
self._relativeMaximums[attr] /
maxRes
)
self._trackedWidgets[attr].setMaximum(int(newMaximumValue))
class ComponentError(RuntimeError):
'''Gives the MainWindow a traceback to display, and cancels the export.'''
prevErrors = []
lastTime = time.time()
def __init__(self, caller, name, msg=None):
if msg is None and sys.exc_info()[0] is not None:
msg = str(sys.exc_info()[1])
else:
msg = 'Unknown error.'
log.error("ComponentError by %s's %s: %s" % (
caller.name, name, msg))
# Don't create multiple windows for quickly repeated messages
if len(ComponentError.prevErrors) > 1:
ComponentError.prevErrors.pop()
ComponentError.prevErrors.insert(0, name)
curTime = time.time()
if name in ComponentError.prevErrors[1:] \
and curTime - ComponentError.lastTime < 1.0:
return
ComponentError.lastTime = time.time()
from toolkit import formatTraceback
if sys.exc_info()[0] is not None:
string = (
"%s component (#%s): %s encountered %s %s: %s" % (
caller.__class__.name,
str(caller.compPos),
name,
'an' if any([
sys.exc_info()[0].__name__.startswith(vowel)
for vowel in ('A', 'I', 'U', 'O', 'E')
]) else 'a',
sys.exc_info()[0].__name__,
str(sys.exc_info()[1])
)
)
detail = formatTraceback(sys.exc_info()[2])
else:
string = name
detail = "Attributes:\n%s" % (
"\n".join(
[m for m in dir(caller) if not m.startswith('_')]
)
)
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