diff --git a/src/component.py b/src/component.py index 0e5144c..dcba082 100644 --- a/src/component.py +++ b/src/component.py @@ -12,7 +12,7 @@ import logging from toolkit.frame import BlankFrame from toolkit import ( - getWidgetValue, setWidgetValue, connectWidget, rgbFromString + getWidgetValue, setWidgetValue, connectWidget, rgbFromString, blockSignals ) @@ -305,14 +305,46 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): 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. + A component update triggered by the user changing a widget value + Call super() at the END when subclassing this. ''' - for attr, widget in self._trackedWidgets.items(): + oldWidgetVals = { + attr: 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() + } + if any([val != oldWidgetVals[attr] + for attr, val in newWidgetVals.items() + ]): + action = ComponentUpdate(self, oldWidgetVals, newWidgetVals) + self.parent.undoStack.push(action) + + def _update(self): + '''An internal component update that is not undoable''' + + 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 preset 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: text stored as tuple & update the button color - rgbTuple = rgbFromString(widget.text()) + if type(val) is tuple: + rgbTuple = val + else: + rgbTuple = rgbFromString(val) btnStyle = ( "QPushButton { background-color : %s; outline: none; }" % QColor(*rgbTuple).name()) @@ -322,12 +354,11 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): elif attr in self._relativeWidgets: # Relative widgets: number scales to fit export resolution self.updateRelativeWidget(attr) - setattr(self, attr, self._trackedWidgets[attr].value()) + setattr(self, attr, val) else: # Normal tracked widget - setattr(self, attr, getWidgetValue(widget)) - self.sendUpdateSignal() + setattr(self, attr, val) def sendUpdateSignal(self): if not self.core.openingProject: @@ -541,7 +572,6 @@ class Component(QtCore.QObject, metaclass=ComponentMetaclass): pixelVal = self.pixelValForAttr(attr, floatVal) self._trackedWidgets[attr].setValue(pixelVal) - def updateRelativeWidget(self, attr): try: oldUserValue = getattr(self, attr) @@ -628,3 +658,30 @@ 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, newWidgetVals): + super().__init__( + 'Changed %s component #%s' % ( + parent.name, parent.compPos + ) + ) + self.parent = parent + self.oldWidgetVals = oldWidgetVals + self.newWidgetVals = newWidgetVals + + def redo(self): + self.parent.setAttrs(self.newWidgetVals) + self.parent.sendUpdateSignal() + + def undo(self): + self.parent.setAttrs(self.oldWidgetVals) + with blockSignals(self.parent): + for attr, widget in self.parent._trackedWidgets.items(): + val = self.oldWidgetVals[attr] + if attr in self.parent._colorWidgets: + val = '%s,%s,%s' % val + setWidgetValue(widget, val) + self.parent.sendUpdateSignal() diff --git a/src/components/color.py b/src/components/color.py index 5d1233e..d09cee8 100644 --- a/src/components/color.py +++ b/src/components/color.py @@ -17,9 +17,6 @@ class Component(Component): self.y = 0 super().widget(*args) - self.page.lineEdit_color1.setText('0,0,0') - self.page.lineEdit_color2.setText('133,133,133') - # disable color #2 until non-default 'fill' option gets changed self.page.lineEdit_color2.setDisabled(True) self.page.pushButton_color2.setDisabled(True) diff --git a/src/components/color.ui b/src/components/color.ui index a9dacea..1865e60 100644 --- a/src/components/color.ui +++ b/src/components/color.ui @@ -73,6 +73,9 @@ 0 + + 0,0,0 + 12 @@ -146,6 +149,9 @@ 0 + + 133,133,133 + 12 diff --git a/src/components/text.py b/src/components/text.py index 4d4f5d3..d3afd5c 100644 --- a/src/components/text.py +++ b/src/components/text.py @@ -13,8 +13,6 @@ class Component(Component): def widget(self, *args): super().widget(*args) - self.textColor = (255, 255, 255) - self.strokeColor = (0, 0, 0) self.title = 'Text' self.alignment = 1 self.titleFont = QFont() @@ -25,8 +23,6 @@ class Component(Component): self.page.comboBox_textAlign.addItem("Right") self.page.comboBox_textAlign.setCurrentIndex(int(self.alignment)) - self.page.lineEdit_textColor.setText('%s,%s,%s' % self.textColor) - self.page.lineEdit_strokeColor.setText('%s,%s,%s' % self.strokeColor) self.page.spinBox_fontSize.setValue(int(self.fontSize)) self.page.lineEdit_title.setText(self.title) diff --git a/src/components/text.ui b/src/components/text.ui index 13d3467..b62e0ed 100644 --- a/src/components/text.ui +++ b/src/components/text.ui @@ -427,6 +427,9 @@ Qt::NoFocus + + 255,255,255 + @@ -485,6 +488,9 @@ Qt::NoFocus + + 0,0,0 + diff --git a/src/core.py b/src/core.py index 20b9c1d..cee0f56 100644 --- a/src/core.py +++ b/src/core.py @@ -94,12 +94,11 @@ class Core: compPos, component ) - self.componentListChanged() - if moduleIndex > -1: - self.updateComponent(compPos) - if hasattr(loader, 'insertComponent'): loader.insertComponent(compPos) + + self.componentListChanged() + self.updateComponent(compPos) return compPos def moveComponent(self, startI, endI): @@ -119,7 +118,7 @@ class Core: def updateComponent(self, i): log.debug('Updating %s #%s' % (self.selectedComponents[i], str(i))) - self.selectedComponents[i].update() + self.selectedComponents[i]._update() def moduleIndexFor(self, compName): try: @@ -540,6 +539,7 @@ class Core: "projectDir": os.path.join(cls.dataDir, 'projects'), "pref_insertCompAtTop": True, "pref_genericPreview": True, + "pref_undoLimit": 10, } for parm, value in cls.defaultSettings.items(): @@ -552,8 +552,14 @@ class Core: if not key.startswith('pref_'): continue val = cls.settings.value(key) - if val in ('true', 'false'): - cls.settings.setValue(key, True if val == 'true' else False) + try: + val = int(val) + except ValueError: + if val == 'true': + val = True + elif val == 'false': + val = False + cls.settings.setValue(key, val) @staticmethod def makeLogger(): diff --git a/src/background.png b/src/gui/background.png similarity index 100% rename from src/background.png rename to src/gui/background.png diff --git a/src/gui/mainwindow.py b/src/gui/mainwindow.py index 2edb750..47111a0 100644 --- a/src/gui/mainwindow.py +++ b/src/gui/mainwindow.py @@ -42,13 +42,22 @@ class MainWindow(QtWidgets.QMainWindow): def __init__(self, window, project): QtWidgets.QMainWindow.__init__(self) + log.debug( + 'Main thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) self.window = window self.core = Core() Core.mode = 'GUI' - log.debug( - 'Main thread id: {}'.format(int(QtCore.QThread.currentThreadId()))) + # Find settings created by Core object + self.dataDir = Core.dataDir + self.presetDir = Core.presetDir + self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') + self.settings = Core.settings + + # Create stack of undoable user actions self.undoStack = QtWidgets.QUndoStack(self) + undoLimit = self.settings.value("pref_undoLimit") + self.undoStack.setUndoLimit(undoLimit) # widgets of component settings self.pages = [] @@ -58,12 +67,6 @@ class MainWindow(QtWidgets.QMainWindow): self.autosaveCooldown = 0.2 self.encoding = False - # Find settings created by Core object - self.dataDir = Core.dataDir - self.presetDir = Core.presetDir - self.autosavePath = os.path.join(self.dataDir, 'autosave.avp') - self.settings = Core.settings - self.presetManager = PresetManager( uic.loadUi( os.path.join(Core.wd, 'gui', 'presetmanager.ui')), self) @@ -302,6 +305,7 @@ class MainWindow(QtWidgets.QMainWindow): QtWidgets.QShortcut("Ctrl+A", self.window, self.openSaveProjectDialog) QtWidgets.QShortcut("Ctrl+O", self.window, self.openOpenProjectDialog) QtWidgets.QShortcut("Ctrl+N", self.window, self.createNewProject) + QtWidgets.QShortcut("Ctrl+Z", self.window, self.undoStack.undo) QtWidgets.QShortcut("Ctrl+Y", self.window, self.undoStack.redo) QtWidgets.QShortcut("Ctrl+Shift+Z", self.window, self.undoStack.redo) @@ -353,6 +357,9 @@ class MainWindow(QtWidgets.QMainWindow): QtWidgets.QShortcut( "Ctrl+Alt+Shift+F", self.window, self.showFfmpegCommand ) + QtWidgets.QShortcut( + "Ctrl+Alt+Shift+U", self.window, self.showUndoStack + ) @QtCore.pyqtSlot() def cleanUp(self, *args): @@ -658,6 +665,14 @@ class MainWindow(QtWidgets.QMainWindow): def showPreviewImage(self, image): self.previewWindow.changePixmap(image) + def showUndoStack(self): + dialog = QtWidgets.QDialog(self.window) + undoView = QtWidgets.QUndoView(self.undoStack) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(undoView) + dialog.setLayout(layout) + dialog.show() + def showFfmpegCommand(self): from textwrap import wrap from toolkit.ffmpeg import createFfmpegCommand @@ -784,6 +799,7 @@ class MainWindow(QtWidgets.QMainWindow): field.blockSignals(False) self.progressBarUpdated(0) self.progressBarSetText('') + self.undoStack.clear() @disableWhenEncoding def createNewProject(self, prompt=True): @@ -847,7 +863,7 @@ class MainWindow(QtWidgets.QMainWindow): def openProject(self, filepath, prompt=True): if not filepath or not os.path.exists(filepath) \ - or not filepath.endswith('.avp'): + or not filepath.endswith('.avp'): return self.clear() diff --git a/src/toolkit/common.py b/src/toolkit/common.py index eba57d9..51ad023 100644 --- a/src/toolkit/common.py +++ b/src/toolkit/common.py @@ -9,6 +9,18 @@ import subprocess from collections import OrderedDict +class blockSignals: + '''A context manager to temporarily block a Qt widget from updating''' + def __init__(self, widget): + self.widget = widget + + def __enter__(self): + self.widget.blockSignals(True) + + def __exit__(self, *args): + self.widget.blockSignals(False) + + def badName(name): '''Returns whether a name contains non-alphanumeric chars''' return any([letter in string.punctuation for letter in name]) diff --git a/src/toolkit/frame.py b/src/toolkit/frame.py index ad8537c..2104978 100644 --- a/src/toolkit/frame.py +++ b/src/toolkit/frame.py @@ -98,7 +98,7 @@ def Checkerboard(width, height): log.debug('Creating new %s*%s checkerboard' % (width, height)) image = FloodFrame(1920, 1080, (0, 0, 0, 0)) image.paste(Image.open( - os.path.join(core.Core.wd, "background.png")), + os.path.join(core.Core.wd, 'gui', "background.png")), (0, 0) ) image = image.resize((width, height))