class QLayerView(QTableView): """ Display the stack of image layers. """ def __init__(self, parent): super().__init__(parent) self.img = None # graphic form to show : it # should correspond to the currently selected layer self.currentWin = None # mouse click event self.clicked.connect(self.viewClicked) # set behavior and styles self.setSelectionBehavior(QAbstractItemView.SelectRows) delegate = itemDelegate(parent=self) self.setItemDelegate(delegate) ic1 = QImage(":/images/resources/eye-icon.png") ic2 = QImage(":/images/resources/eye-icon-strike.png") delegate.px1 = QPixmap.fromImage(ic1) delegate.px2 = QPixmap.fromImage(ic2) ic1.invertPixels() ic2.invertPixels() delegate.inv_px1 = QPixmap.fromImage(ic1) delegate.inv_px2 = QPixmap.fromImage(ic2) self.setIconSize(QSize(20, 15)) self.verticalHeader().setMinimumSectionSize(-1) self.verticalHeader().setDefaultSectionSize( self.verticalHeader().minimumSectionSize()) self.horizontalHeader().setMinimumSectionSize(40) self.horizontalHeader().setDefaultSectionSize(40) # drag and drop self.setDragDropMode(QAbstractItemView.DragDrop) self.setDefaultDropAction(Qt.MoveAction) self.setDragDropOverwriteMode(False) self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) ################################ # layer property GUI : # preview, blending mode, opacity, mask color ################################ # Preview option # We should use a QListWidget or a custom optionsWidget # (cf. utils.py) : adding it to QVBoxLayout with mode # Qt.AlignBottom does not work. self.previewOptionBox = QCheckBox('Preview') self.previewOptionBox.setMaximumSize(100, 30) # View/Preview changed slot def m(state): # state : Qt.Checked Qt.UnChecked if self.img is None: return useThumb = (state == Qt.Checked) if useThumb == self.img.useThumb: return self.img.useThumb = useThumb window.updateStatus() self.img.cacheInvalidate() try: QApplication.setOverrideCursor( Qt.WaitCursor ) # TODO 18/04/18 waitcursor already called by applytostack QApplication.processEvents() # update the whole stack self.img.layersStack[0].applyToStack() self.img.onImageChanged() finally: QApplication.restoreOverrideCursor() QApplication.processEvents() self.previewOptionBox.stateChanged.connect(m) self.previewOptionBox.setChecked(True) # m is not triggered # title titleLabel = QLabel('Layer') titleLabel.setMaximumSize(100, 30) # opacity slider self.opacitySlider = QbLUeSlider(Qt.Horizontal) self.opacitySlider.setStyleSheet( QbLUeSlider.bLueSliderDefaultBWStylesheet) self.opacitySlider.setTickPosition(QSlider.TicksBelow) self.opacitySlider.setRange(0, 100) self.opacitySlider.setSingleStep(1) self.opacitySlider.setSliderPosition(100) self.opacityValue = QLabel() font = self.opacityValue.font() metrics = QFontMetrics(font) w = metrics.width("100 ") h = metrics.height() self.opacityValue.setMinimumSize(w, h) self.opacityValue.setMaximumSize(w, h) self.opacityValue.setText('100 ') # opacity value changed event handler def f1(): self.opacityValue.setText(str('%d ' % self.opacitySlider.value())) # opacity slider released event handler def f2(): try: layer = self.img.getActiveLayer() layer.setOpacity(self.opacitySlider.value()) layer.applyToStack() self.img.onImageChanged() except AttributeError: return self.opacitySlider.valueChanged.connect(f1) self.opacitySlider.sliderReleased.connect(f2) # mask color slider self.maskLabel = QLabel('Mask Color') maskSlider = QbLUeSlider(Qt.Horizontal) maskSlider.setStyleSheet(QbLUeSlider.bLueSliderDefaultBWStylesheet) maskSlider.setTickPosition(QSlider.TicksBelow) maskSlider.setRange(0, 100) maskSlider.setSingleStep(1) maskSlider.setSliderPosition(100) self.maskSlider = maskSlider self.maskValue = QLabel() font = self.maskValue.font() metrics = QFontMetrics(font) w = metrics.width("100 ") h = metrics.height() self.maskValue.setMinimumSize(w, h) self.maskValue.setMaximumSize(w, h) self.maskValue.setText('100 ') # masks are disbled by default self.maskLabel.setEnabled(False) self.maskSlider.setEnabled(False) self.maskValue.setEnabled(False) # mask value changed event handler def g1(): self.maskValue.setText(str('%d ' % self.maskSlider.value())) # mask slider released event handler def g2(): try: layer = self.img.getActiveLayer() layer.setColorMaskOpacity(self.maskSlider.value() * 255.0 / 100.0) layer.applyToStack() self.img.onImageChanged() except AttributeError: return self.maskSlider.valueChanged.connect(g1) self.maskSlider.sliderReleased.connect(g2) # blending mode combo box compLabel = QLabel() compLabel.setText("Blend") self.compositionModeDict = OrderedDict([ ('Normal', QPainter.CompositionMode_SourceOver), ('Plus', QPainter.CompositionMode_Plus), ('Multiply', QPainter.CompositionMode_Multiply), ('Screen', QPainter.CompositionMode_Screen), ('Overlay', QPainter.CompositionMode_Overlay), ('Darken', QPainter.CompositionMode_Darken), ('Lighten', QPainter.CompositionMode_Lighten), ('Color Dodge', QPainter.CompositionMode_ColorDodge), ('Color Burn', QPainter.CompositionMode_ColorBurn), ('Hard Light', QPainter.CompositionMode_HardLight), ('Soft Light', QPainter.CompositionMode_SoftLight), ('Difference', QPainter.CompositionMode_Difference), ('Exclusion', QPainter.CompositionMode_Exclusion), # Type of previous modes is QPainter.CompositionMode (Shiboken enum-type). # Next additional modes are not implemented by QPainter: ('Luminosity', -1), ('color', -2) ]) self.blendingModeCombo = QComboBox() for key in self.compositionModeDict: self.blendingModeCombo.addItem(key, self.compositionModeDict[key]) # combo box item changed slot def g(ind): layer = self.img.getActiveLayer() s = self.blendingModeCombo.currentText() newMode = self.compositionModeDict[str(s)] if newMode == layer.compositionMode: return layer.compositionMode = newMode layer.applyToStack() self.img.onImageChanged() self.blendingModeCombo.currentIndexChanged.connect(g) # layout l = QVBoxLayout() l.setAlignment(Qt.AlignTop) hl0 = QHBoxLayout() hl0.addWidget(titleLabel) hl0.addStretch(1) hl0.addWidget(self.previewOptionBox) l.addLayout(hl0) hl = QHBoxLayout() hl.addWidget(QLabel('Opacity')) hl.addWidget(self.opacityValue) hl.addWidget(self.opacitySlider) l.addLayout(hl) hl1 = QHBoxLayout() hl1.addWidget(self.maskLabel) hl1.addWidget(self.maskValue) hl1.addWidget(self.maskSlider) l.addLayout(hl1) l.setContentsMargins(0, 0, 10, 0) # left, top, right, bottom hl2 = QHBoxLayout() hl2.addWidget(compLabel) hl2.addWidget(self.blendingModeCombo) l.addLayout(hl2) for layout in [hl, hl1, hl2]: layout.setContentsMargins(5, 0, 0, 0) # this layout must be added to the propertyWidget object loaded from blue.ui : # we postpone it to in blue.py, after loading the main form. self.propertyLayout = l # shortcut actions self.actionDup = QAction('Duplicate layer', None) self.actionDup.setShortcut(QKeySequence(Qt.CTRL + Qt.Key_J)) self.addAction(self.actionDup) def dup(): row = self.selectedIndexes()[0].row() # Stack index index = len(self.img.layersStack) - row - 1 layer = self.img.layersStack[index] if layer.isAdjustLayer(): return # add new layer to stack and set it to active self.img.dupLayer(index=index) # update layer view self.setLayers(self.img) self.actionDup.triggered.connect(dup) self.setWhatsThis("""<b>Layer Stack</b><br> To <b>toggle the layer visibility</b> click on the Eye icon.<br> To <b>add a mask</b> use the context menu to enable it and paint pixels with the Mask/Unmask tools in the left pane.<br> For <b>color mask<b/b>: <br> green pixels are masked,<br> red pixels are unmasked.<br> Other colors correspond to partially masked pixels.<br> Note that upper visible layers slow down mask edition.<br> """) # end of setWhatsThis def closeAdjustForms(self, delete=False): """ Close all windows associated with image layers. If delete is True (default False), the windows and their dock containers are deleted. @param delete: @type delete: boolean """ if self.img is None: return stack = self.img.layersStack for layer in stack: layer.closeView(delete=delete) if delete: self.currentWin = None gc.collect() def clear(self, delete=True): """ Reset LayerView and clear the back links to image. """ self.closeAdjustForms(delete=delete) self.img = None self.currentWin = None # model = layerModel() # model.setColumnCount(3) # TODO removed 21/01/20 validate self.setModel(None) def setLayers(self, mImg, delete=False): """ Displays the layer stack of a mImage instance. @param mImg: image @type mImg: mImage @param delete: @type delete: """ # close open adjustment windows # self.closeAdjustForms() self.clear(delete=delete) mImg.layerView = self # back link to image self.img = weakProxy(mImg) model = layerModel() model.setColumnCount(3) l = len(mImg.layersStack) # dataChanged event handler : enables edition of layer name def f(index1, index2): # index1 and index2 should be equal # only layer name should be editable # dropEvent emit dataChanged when setting item values. f must # return immediately from these calls, as they are possibly made with unconsistent data : # dragged rows are already removed from layersStack # and not yet removed from model. if l != self.model().rowCount(): return # only name is editable if index1.column() != 1: return row = index1.row() stackIndex = l - row - 1 mImg.layersStack[stackIndex].name = index1.data() model.dataChanged.connect(f) for r, lay in enumerate(reversed(mImg.layersStack)): try: lay.maskSettingsChanged.sig.disconnect() except RuntimeError: pass lay.maskSettingsChanged.sig.connect(self.updateRows) items = [] # col 0 : visibility icon if lay.visible: item_visible = QStandardItem( QIcon(":/images/resources/eye-icon.png"), "") else: item_visible = QStandardItem( QIcon(":/images/resources/eye-icon-strike.png"), "") items.append(item_visible) # col 1 : image icon (for non-adjustment layer only) and name if len(lay.name) <= 30: name = lay.name else: name = lay.name[:28] + '...' if hasattr(lay, 'inputImg'): item_name = QStandardItem(name) else: # icon with very small dim causes QPainter error # QPixmap.fromImage bug ? smallImg = lay.resized(50, 50) w, h = smallImg.width(), smallImg.height() if w < h / 5 or h < w / 5: item_name = QStandardItem(name) else: item_name = QStandardItem( QIcon(QPixmap.fromImage(smallImg)), name) # set tool tip to full name item_name.setToolTip(lay.name) items.append(item_name) item_mask = QStandardItem('m') items.append(item_mask) model.appendRow(items) self.setModel(model) self.horizontalHeader().hide() self.verticalHeader().hide() header = self.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # lay out the graphic forms into right pane forms = [ item.view for item in mImg.layersStack if getattr(item, 'view', None) is not None ] for dock in forms: if TABBING: dockedForms = [item for item in forms if not item.isFloating()] dock.show() # needed to get working tabbing if dock not in dockedForms and dock.tabbed: if dockedForms: window.tabifyDockWidget(dockedForms[-1], dock) else: window.addDockWidget(Qt.RightDockWidgetArea, dock) dock.setFloating(False) # select active layer self.selectRow(len(mImg.layersStack) - 1 - mImg.activeLayerIndex) activeLayer = mImg.getActiveLayer() layerview = activeLayer.view if layerview is not None: layerview.show() if TABBING: layerview.raise_() # lay out subcontrols of activeLayer form = layerview.widget() for dk in form.subControls: dk.setVisible(form.options[dk.widget().optionName]) # clean up: we (re)dock all sucontrols dk.setFloating(False) # emit topLevelChanged signal self.opacitySlider.setSliderPosition(int(activeLayer.opacity * 100)) self.maskSlider.setSliderPosition( int(activeLayer.colorMaskOpacity * 100.0 / 255.0)) ind = self.blendingModeCombo.findData(activeLayer.compositionMode) self.blendingModeCombo.setCurrentIndex(ind) self.previewOptionBox.setChecked(activeLayer.parentImage.useThumb) #activeLayer.maskColor self.updateForm() """ # TODO removed 25/01/20 useless validate for item in self.img.layersStack: if hasattr(item, 'sourceIndex'): combo = item.getGraphicsForm().sourceCombo currentText = combo.currentText() combo.clear() for i, x in enumerate(self.img.layersStack): item.view.widget().sourceCombo.addItem(x.name, i) combo.setCurrentIndex(combo.findText(currentText)) """ def updateForm(self): activeLayer = self.img.getActiveLayer() if hasattr(activeLayer, 'view'): self.currentWin = activeLayer.view if self.currentWin is not None: self.currentWin.show() self.currentWin.activateWindow() def updateRow(self, row): minInd, maxInd = self.model().index(row, 0), self.model().index(row, 3) self.model().dataChanged.emit(minInd, maxInd) def updateRows(self): """ Update all rows. """ count = self.model().rowCount() minInd, maxInd = self.model().index(0, 0), self.model().index( count - 1, 3) self.model().dataChanged.emit(minInd, maxInd) def dropEvent(self, event): """ drop event handler : moving layer @param event: @type event: Qevent """ if event.source() is not self: return # get selected rows and layers rows = set([mi.row() for mi in self.selectedIndexes()]) rStack = self.img.layersStack[::-1] layers = [rStack[i] for i in rows] # get target row and layer targetRow = self.indexAt(event.pos()).row() targetLayer = rStack[targetRow] # remove target from selection if targetRow in rows: rows.discard(targetRow) rows = sorted(rows) if not rows: return # if target is below last row insert at the last position if targetRow == -1: targetRow = self.model().rowCount() # mapping of src (row) indices to target indices rowMapping = dict() for idx, row in enumerate(rows): if row < targetRow: rowMapping[row] = targetRow + idx else: rowMapping[row + len(rows)] = targetRow + idx # update layerStack using rowMapping # insert None items for _ in range(len(rows)): rStack.insert(targetRow, None) # copy moved items to their final place for srcRow, tgtRow in sorted( rowMapping.items()): # python 3 iteritems->items rStack[tgtRow] = rStack[srcRow] # remove moved items from their initial place for row in reversed(sorted( rowMapping.keys())): # python 3 iterkeys -> keys rStack.pop(row) self.img.layersStack = rStack[::-1] # update model # insert empty rows for _ in range(len(rows)): result = self.model().insertRow(targetRow, QModelIndex()) # copy moved rows to their final place colCount = self.model().columnCount() for srcRow, tgtRow in sorted( rowMapping.items()): # python 3 iteritems->items for col in range(0, colCount): # CAUTION : setItem calls the data changed event handler (cf. setLayers above) self.model().setItem(tgtRow, col, self.model().takeItem(srcRow, col)) # remove moved rows from their initial place and keep track of moved items movedDict = rowMapping.copy() for row in reversed(sorted( rowMapping.keys())): # python 3 iterkeys -> keys self.model().removeRow(row) for s, t in rowMapping.items(): if t > row: movedDict[s] -= 1 ######################################### sanity check for r in range(self.model().rowCount()): id = self.model().index(r, 1) if id.data() != rStack[r].name: raise ValueError('Drop Error') ######################################## # reselect moved rows sel = sorted(movedDict.values()) selectionModel = QtCore.QItemSelectionModel(self.model()) self.setSelectionModel(selectionModel) index1 = self.model().index(sel[0], 1) index2 = self.model().index(sel[-1], 1) itemSelection = QtCore.QItemSelection(index1, index2) self.selectionModel().select( itemSelection, QtCore.QItemSelectionModel.Rows | QtCore.QItemSelectionModel.Select) # multiple selection : display no window if len(sel) > 1: self.currentWin.hide() self.currentWin = None elif len(sel) == 1: self.img.setActiveLayer(len(self.img.layersStack) - sel[0] - 1) # update stack self.img.layersStack[0].applyToStack() self.img.onImageChanged() def select(self, row, col): """ select item in view @param row: @type row: @param col: @type col: @return: @rtype: """ model = self.model() self.viewClicked(model.index(row, col)) def viewClicked(self, clickedIndex): """ Mouse clicked event handler. @param clickedIndex: @type clickedIndex: QModelIndex """ row = clickedIndex.row() rows = set([mi.row() for mi in self.selectedIndexes()]) # multiple selection : go to top of selection m = min(rows) if row != m: clickedIndex = self.model().index(m, clickedIndex.column()) layer = self.img.layersStack[-1 - row] self.actionDup.setEnabled(not layer.isAdjustLayer()) # toggle layer visibility if clickedIndex.column() == 0: # background layer is always visible if row == len(self.img.layersStack) - 1: return # layer.visible = not(layer.visible) layer.setVisible(not layer.visible) if self.currentWin is not None: self.currentWin.setVisible(layer.visible) if not layer.visible: self.currentWin = None if layer.tool is not None: layer.tool.setVisible(layer.visible) # update stack if layer.visible: layer.applyToStack() else: i = layer.getUpperVisibleStackIndex() if i >= 0: layer.parentImage.layersStack[i].applyToStack() else: # top layer : update only the presentation layer layer.parentImage.prLayer.execute(l=None, pool=None) self.img.onImageChanged() # update displayed window and active layer activeStackIndex = len(self.img.layersStack) - 1 - row activeLayer = self.img.setActiveLayer(activeStackIndex) # update color mask slider and label self.maskLabel.setEnabled(layer.maskIsSelected) self.maskSlider.setEnabled(activeLayer.maskIsSelected) self.maskValue.setEnabled(activeLayer.maskIsSelected) if self.currentWin is not None: # hide sucontrols for dk in self.currentWin.widget().subControls: dk.hide() if self.currentWin.isFloating(): self.currentWin.hide() self.currentWin = None if hasattr(activeLayer, "view"): self.currentWin = activeLayer.view if self.currentWin is not None and activeLayer.visible: self.currentWin.show() self.currentWin.raise_() # display subcontrols for dk in self.currentWin.widget().subControls: dk.setVisible( self.currentWin.widget().options[dk.widget().optionName]) # make self.currentWin the active window self.currentWin.activateWindow() # update opacity and composition mode for current layer opacity = int(layer.opacity * 100) self.opacityValue.setText(str('%d ' % opacity)) self.opacitySlider.setSliderPosition(opacity) compositionMode = layer.compositionMode ind = self.blendingModeCombo.findData(compositionMode) self.blendingModeCombo.setCurrentIndex(ind) # draw the right rectangle window.label.repaint() def initContextMenu(self): """ Context menu initialization @return: @rtype: QMenu """ menu = QMenu() # menu.actionReset = QAction('Reset To Default', None) # menu.actionLoadImage = QAction('Load New Image', None) # multiple selections menu.actionMerge = QAction('Merge Lower', None) # merge only adjustment layer with image layer menu.actionRepositionLayer = QAction('Reposition Layer(s)', None) menu.actionColorMaskEnable = QAction('Color', None) menu.actionOpacityMaskEnable = QAction('Opacity', None) menu.actionClippingMaskEnable = QAction('Clipping', None) menu.actionMaskDisable = QAction('Disabled', None) menu.actionMaskUndo = QAction('Undo Mask', None) menu.actionMaskRedo = QAction('Redo Mask', None) menu.actionMaskInvert = QAction('Invert Mask', None) menu.actionMaskReset_UM = QAction('Unmask All', None) menu.actionMaskReset_M = QAction('Mask All', None) menu.actionMaskCopy = QAction('Copy Mask to Clipboard', None) menu.actionImageCopy = QAction('Copy Image to Clipboard', None) menu.actionMaskPaste = QAction('Paste Mask', None) menu.actionImagePaste = QAction('Paste Image', None) menu.actionMaskDilate = QAction('Dilate Mask', None) menu.actionMaskErode = QAction('Erode Mask', None) menu.actionMaskSmooth = QAction('Smooth Mask', None) menu.actionMaskBright1 = QAction('Bright 1 Mask', None) menu.actionMaskBright2 = QAction('Bright 2 Mask', None) menu.actionMaskBright3 = QAction('Bright 3 Mask', None) menu.actionMaskDark1 = QAction('Dark 1 Mask', None) menu.actionMaskDark2 = QAction('Dark 2 Mask', None) menu.actionMaskDark3 = QAction('Dark 3 Mask', None) menu.actionMaskMid1 = QAction('Mid 1 Mask', None) menu.actionMaskMid2 = QAction('Mid 2 Mask', None) menu.actionMaskMid3 = QAction('Mid 3 Mask', None) menu.actionMergingFlag = QAction('Merged Layer', None) menu.actionMergingFlag.setCheckable(True) menu.actionColorMaskEnable.setCheckable(True) menu.actionOpacityMaskEnable.setCheckable(True) menu.actionClippingMaskEnable.setCheckable(True) menu.actionMaskDisable.setCheckable(True) #################### # Build menu ################### menu.addAction(menu.actionRepositionLayer) menu.addSeparator() # layer menu.addAction(menu.actionImageCopy) menu.addAction(menu.actionImagePaste) menu.addAction(menu.actionMergingFlag) menu.addSeparator() # mask menu.subMenuEnable = menu.addMenu('Mask...') menu.subMenuEnable.addAction(menu.actionColorMaskEnable) menu.subMenuEnable.addAction(menu.actionOpacityMaskEnable) menu.subMenuEnable.addAction(menu.actionClippingMaskEnable) menu.subMenuEnable.addAction(menu.actionMaskDisable) menu.addAction(menu.actionMaskUndo) menu.addAction(menu.actionMaskRedo) menu.addAction(menu.actionMaskInvert) menu.subMenuLum = menu.addMenu('Luminosity Mask...') for a in [ menu.actionMaskBright1, menu.actionMaskBright2, menu.actionMaskBright3, menu.actionMaskDark1, menu.actionMaskDark2, menu.actionMaskDark3, menu.actionMaskMid1, menu.actionMaskMid2, menu.actionMaskMid3 ]: menu.subMenuLum.addAction(a) menu.addAction(menu.actionMaskReset_UM) menu.addAction(menu.actionMaskReset_M) menu.addAction(menu.actionMaskCopy) menu.addAction(menu.actionMaskPaste) menu.addAction(menu.actionMaskDilate) menu.addAction(menu.actionMaskErode) menu.addAction(menu.actionMaskSmooth) menu.addSeparator() # miscellaneous # menu.addAction(menu.actionLoadImage) # to link actionDup with a shortcut, # it must be set in __init__ menu.addAction(self.actionDup) menu.addAction(menu.actionMerge) # menu.addAction(menu.actionReset) return menu def contextMenuEvent(self, event): """ context menu handler @param event @type event: QContextMenuEvent """ selection = self.selectedIndexes() if not selection: return # get a fresh context menu without connected actions # and with state corresponding to the currently clicked layer self.cMenu = self.initContextMenu() # get current selection rows = set([mi.row() for mi in selection]) rStack = self.img.layersStack[::-1] layers = [rStack[r] for r in rows] # get current position index = self.indexAt(event.pos()) layerStackIndex = len(self.img.layersStack) - 1 - index.row() layer = self.img.layersStack[layerStackIndex] lowerVisible = self.img.layersStack[layer.getLowerVisibleStackIndex()] lower = self.img.layersStack[layerStackIndex - 1] # case index == 0 doesn't matter # toggle actions self.cMenu.actionMergingFlag.setChecked(layer.mergingFlag) self.cMenu.actionMerge.setEnabled(not ( hasattr(layer, 'inputImg') or hasattr(lowerVisible, 'inputImg'))) self.actionDup.setEnabled(not layer.isAdjustLayer()) self.cMenu.actionColorMaskEnable.setChecked(layer.maskIsSelected and layer.maskIsEnabled) self.cMenu.actionOpacityMaskEnable.setChecked( (not layer.maskIsSelected) and layer.maskIsEnabled) self.cMenu.actionClippingMaskEnable.setChecked( layer.isClipping and (layer.maskIsSelected or layer.maskIsEnabled)) self.cMenu.actionMaskDisable.setChecked(not ( layer.isClipping or layer.maskIsSelected or layer.maskIsEnabled)) self.cMenu.actionMaskUndo.setEnabled(layer.historyListMask.canUndo()) self.cMenu.actionMaskRedo.setEnabled(layer.historyListMask.canRedo()) self.cMenu.subMenuEnable.setEnabled(len(rows) == 1) self.cMenu.actionMaskPaste.setEnabled( not QApplication.clipboard().image().isNull()) self.cMenu.actionImagePaste.setEnabled( not QApplication.clipboard().image().isNull()) self.cMenu.actionMergingFlag.setEnabled(layer.isImageLayer()) # Event handlers def RepositionLayer(): layer.xOffset, layer.yOffset = 0, 0 layer.Zoom_coeff = 1.0 layer.AltZoom_coeff = 1.0 layer.xAltOffset, layer.yAltOffset = 0, 0 layer.updatePixmap() self.img.onImageChanged() def merge(): layer.merge_with_layer_immediately_below() def testUpperVisibility(): pos = self.img.getStackIndex(layer) upperVisible = False for i in range(len(self.img.layersStack) - pos - 1): if self.img.layersStack[pos + 1 + i].visible: upperVisible = True break if upperVisible: dlgWarn("Upper visible layers slow down mask edition") return True return False def colorMaskEnable(): testUpperVisibility() layer.maskIsEnabled = True layer.maskIsSelected = True self.maskLabel.setEnabled(layer.maskIsSelected) self.maskSlider.setEnabled(layer.maskIsSelected) self.maskValue.setEnabled(layer.maskIsSelected) layer.applyToStack() self.img.onImageChanged() def opacityMaskEnable(): testUpperVisibility() layer.maskIsEnabled = True layer.maskIsSelected = False self.maskLabel.setEnabled(layer.maskIsSelected) self.maskSlider.setEnabled(layer.maskIsSelected) self.maskValue.setEnabled(layer.maskIsSelected) layer.applyToStack() self.img.onImageChanged() def clippingMaskEnable(): layer.maskIsEnabled = True layer.maskIsSelected = False self.maskLabel.setEnabled(layer.maskIsSelected) self.maskSlider.setEnabled(layer.maskIsSelected) self.maskValue.setEnabled(layer.maskIsSelected) layer.isClipping = True layer.applyToStack() self.img.onImageChanged() def maskDisable(): layer.maskIsEnabled = False layer.maskIsSelected = False self.maskLabel.setEnabled(layer.maskIsSelected) self.maskSlider.setEnabled(layer.maskIsSelected) self.maskValue.setEnabled(layer.maskIsSelected) layer.isClipping = False layer.applyToStack() self.img.onImageChanged() def undoMask(): try: layer.mask = layer.historyListMask.undo( saveitem=layer.mask.copy()) layer.applyToStack() self.img.onImageChanged() except ValueError: pass def redoMask(): try: layer.mask = layer.historyListMask.redo() layer.applyToStack() self.img.onImageChanged() except ValueError: pass def maskInvert(): layer.invertMask() # update mask stack layer.applyToStack() self.img.onImageChanged() def maskReset_UM(): layer.resetMask(maskAll=False) # update mask stack for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.execute(l=None, pool=None) self.img.onImageChanged() def maskReset_M(): layer.resetMask(maskAll=True) # update mask stack for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.execute(l=None, pool=None) self.img.onImageChanged() def maskCopy(): QApplication.clipboard().setImage(layer.mask) def imageCopy(): QApplication.clipboard().setImage(layer.getCurrentMaskedImage()) def maskPaste(): """ Pastes clipboard to mask and updates the stack. The clipboard image is scaled if its size does not match the size of the mask """ cb = QApplication.clipboard() if not cb.image().isNull(): img = cb.image() if img.size() == layer.mask.size(): layer.mask = img else: layer.mask = img.scaled(layer.mask.size()) layer.applyToStack() self.img.prLayer.execute(l=None, pool=None) self.img.onImageChanged() def imagePaste(): """ Pastes clipboard to mask and updates the stack. The clipboard image is scaled if its size does not match the size of the mask """ cb = QApplication.clipboard() if not cb.image().isNull(): srcImg = cb.image() if srcImg.size() == layer.size(): layer.setImage(srcImg) else: layer.setImage(srcImg.scaled(layer.size())) layer.applyToStack() self.img.onImageChanged() def maskDilate(): """ Increase the masked part of the image """ buf = QImageBuffer(layer.mask) buf[:, :, 2] = vImage.maskDilate(buf[:, :, 2]) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskErode(): """ Reduce the masked part of the image """ buf = QImageBuffer(layer.mask) buf[:, :, 2] = vImage.maskErode(buf[:, :, 2]) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskSmooth(): """ Smooth the mask boundary """ buf = QImageBuffer(layer.mask) buf[:, :, 2] = vImage.maskSmooth(buf[:, :, 2]) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskBright1(): layer.setMaskLuminosity(min=128, max=255) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskBright2(): layer.setMaskLuminosity(min=192, max=255) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskBright3(): layer.setMaskLuminosity(min=224, max=255) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskDark1(): layer.setMaskLuminosity(min=0, max=128) def maskDark2(): layer.setMaskLuminosity(min=0, max=64) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskDark3(): layer.setMaskLuminosity(min=0, max=32) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskMid1(): layer.setMaskLuminosity(min=64, max=192) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskMid2(): layer.setMaskLuminosity(min=96, max=160) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def maskMid3(): layer.setMaskLuminosity(min=112, max=144) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.prLayer.update() self.img.onImageChanged() def mergingFlag(flag): layer.mergingFlag = flag self.cMenu.actionRepositionLayer.triggered.connect(RepositionLayer) # self.cMenu.actionLoadImage.triggered.connect(loadImage) self.cMenu.actionMerge.triggered.connect(merge) self.cMenu.actionColorMaskEnable.triggered.connect(colorMaskEnable) self.cMenu.actionOpacityMaskEnable.triggered.connect(opacityMaskEnable) self.cMenu.actionClippingMaskEnable.triggered.connect( clippingMaskEnable) self.cMenu.actionMaskDisable.triggered.connect(maskDisable) self.cMenu.actionMaskUndo.triggered.connect(undoMask) self.cMenu.actionMaskRedo.triggered.connect(redoMask) self.cMenu.actionMaskInvert.triggered.connect(maskInvert) self.cMenu.actionMaskReset_UM.triggered.connect(maskReset_UM) self.cMenu.actionMaskReset_M.triggered.connect(maskReset_M) self.cMenu.actionMaskCopy.triggered.connect(maskCopy) self.cMenu.actionMaskPaste.triggered.connect(maskPaste) self.cMenu.actionImageCopy.triggered.connect(imageCopy) self.cMenu.actionImagePaste.triggered.connect(imagePaste) self.cMenu.actionMaskDilate.triggered.connect(maskDilate) self.cMenu.actionMaskErode.triggered.connect(maskErode) self.cMenu.actionMaskSmooth.triggered.connect(maskSmooth) self.cMenu.actionMaskBright1.triggered.connect(maskBright1) self.cMenu.actionMaskBright2.triggered.connect(maskBright2) self.cMenu.actionMaskBright3.triggered.connect(maskBright3) self.cMenu.actionMaskDark1.triggered.connect(maskDark1) self.cMenu.actionMaskDark2.triggered.connect(maskDark2) self.cMenu.actionMaskDark3.triggered.connect(maskDark3) self.cMenu.actionMaskMid1.triggered.connect(maskMid1) self.cMenu.actionMaskMid2.triggered.connect(maskMid2) self.cMenu.actionMaskMid3.triggered.connect(maskMid3) self.cMenu.actionMergingFlag.toggled.connect(mergingFlag) self.cMenu.exec_(event.globalPos() - QPoint(400, 0)) # update table for row in rows: self.updateRow(row)
class main(QWidget): def __init__(self, parent=None): super(main, self).__init__(parent) self.setup() # connections, widgets, layouts, etc. self.blksize = 2**20 # 1 MB; must be divisible by 16 self.ext = '.enc' # extension is appended to encrypted files self.path = '' self.encrypted = [] # to highlight done files in list self.decrypted = [] self.clipboard = QApplication.clipboard() self.timeout = None # to clear message label, see setMessage # this program was just an excuse to play with QprogressBar if not hash(os.urandom(11)) % 11: QTimer().singleShot(50, self.windDown) # various random hints hints = [ 'Freshly encrypted files can be renamed in the table!', 'Clipboard is always cleared on program close!', 'Keys can contain emoji if you <em>really</em> want: \U0001f4e6', 'Keys can contain emoji if you <em>really</em> want: \U0001F511', 'This isn\'t a tip, I just wanted to say hello!', 'Keys can be anywhere from 8 to 4096 characters long!', 'This program was just an excuse to play with the progress bars!', 'Select \'Party\' in the hash button for progress bar fun!', ('Did you know you can donate one or all of your vital organs to ' 'the Aperture Science Self-Esteem Fund for Girls? It\'s true!'), ('It\'s been {:,} days since Half-Life 2: Episode ' 'Two'.format(int((time.time() - 1191988800) / 86400))), 'I\'m version {}!'.format(VERSION), 'I\'m version {}.whatever!'.format(VERSION.split('.')[0]), ('Brought to you by me, I\'m <a href="https://orthallelous.word' 'press.com/">Orthallelous!</a>'), #'Brought to you by me, I\'m Htom Sirveaux!', 'I wonder if there\'s beer on the sun', 'Raspberry World: For all your raspberry needs. Off the beltline', #'I\'ve plummented to my death and I can\'t get up', '<em>NOT</em> compatible with the older version!', ('Hello there, fellow space travellers! Until somebody gives me ' 'some new lines in KAS, that is all I can say. - Bentusi Exchange' ) ] if not hash(os.urandom(9)) % 4: self.extraLabel.setText(random.choice(hints)) def genKey(self): "generate a random key" n = self.keySizeSB.value() char = string.printable.rstrip() #map(chr, range(256)) while len(char) < n: char += char key = ''.join(random.sample(char, n)) self.keyInput.setText(key) def showKey(self, state=None): "hide/show key characters" if state is None: state = bool(self.showKeyCB.checkState()) else: state = bool(state) if state: self.keyInput.setEchoMode(QLineEdit.Normal) else: self.keyInput.setEchoMode(QLineEdit.PasswordEchoOnEdit) def getFolder(self): "open file dialog and fill file table" path = QFileDialog(directory=self.path).getExistingDirectory() if not path: return self.path = str(path) self.populateTable(self.path) self.encrypted, self.decrypted = [], [] return def resizeEvent(self, event): self.showFolder(self.path) # update how the folder is shown def splitterChanged(self, pos): self.showFolder(self.path) # likewise def showFolder(self, path): "displays current path, truncating as needed" if not path: return ell, sl = '\u2026', os.path.sep # ellipsis, slash chars lfg, rfg = Qt.ElideLeft, Qt.ElideRight lst, wdh = os.path.basename(path), self.folderLabel.width() path = path.replace(os.path.altsep or '\\', sl) self.folderLabel.setToolTip(path) # truncate folder location fnt = QFontMetrics(self.folderLabel.font()) txt = str(fnt.elidedText(path, lfg, wdh)) if len(txt) <= 1: # label is way too short self.folderLabel.setText('\u22ee' if txt != sl else txt) return # but when would this happen? # truncate some more (don't show part of a folder name) if len(txt) < len(path) and txt[1] != sl: txt = ell + sl + txt.split(sl, 1)[-1] # don't truncate remaining folder name from the left if txt[2:] != lst and len(txt[2:]) < len(lst) + 2: txt = str(fnt.elidedText(ell + sl + lst, rfg, wdh)) # you'd think len(txt) < len(lst) would work, but no; you'd be wrong self.folderLabel.setText(txt) def populateTable(self, path): "fill file table with file names" self.showFolder(path) names = [] for n in os.listdir(path): if os.path.isdir(os.path.join(path, n)): continue # folder names.append(n) self.folderTable.clearContents() self.folderTable.setRowCount(len(names)) self.folderTable.setColumnCount(1) if not names: # no files in this folder, inform user self.setMessage('This folder has no files') return self.folderTable.blockSignals(True) selEnab = Qt.ItemIsSelectable | Qt.ItemIsEnabled for i, n in enumerate(names): item = QTableWidgetItem() item.setText(n) item.setToolTip(n) item.setFlags(selEnab) # color code encrypted/decrypted files if n in self.encrypted: item.setTextColor(QColor(211, 70, 0)) # allowed encrypted filenames to be changed item.setFlags(selEnab | Qt.ItemIsEditable) if n in self.decrypted: item.setForeground(QColor(0, 170, 255)) self.folderTable.setItem(i, 0, item) if len(names) > 5: self.setMessage('{:,} files'.format(len(names)), 7) self.folderTable.blockSignals(False) return def editFileName(self, item): "change file name" new, old = str(item.text()), str(item.toolTip()) result = QMessageBox.question( self, 'Renaming?', ("<p align='center'>Do you wish to rename<br>" + '<span style="color:#d34600;">{}</span>'.format(old) + "<br>to<br>" + '<span style="color:#ef4b00;">{}</span>'.format(new) + '<br>?</p>')) self.folderTable.blockSignals(True) if any(i in new for i in '/?<>:*|"^'): self.setMessage('Invalid character in name', 7) item.setText(old) elif result == QMessageBox.Yes: oold = os.path.join(self.path, old) try: os.rename(oold, os.path.join(self.path, new)) self.encrypted.remove(old) self.encrypted.append(new) item.setToolTip(new) except Exception as err: self.setMessage(str(err), 9) item.setText(old) item.setToolTip(old) self.encrypted.remove(new) self.encrypted.append(old) else: item.setText(old) self.folderTable.blockSignals(False) def setMessage(self, message, secs=4, col=None): "show a message for a few seconds - col must be rgb triplet tuple" if self.timeout: # https://stackoverflow.com/a/21081371 self.timeout.stop() self.timeout.deleteLater() if col is None: color = 'rgb(255, 170, 127)' else: try: color = 'rgb({}, {}, {})'.format(*col) except: color = 'rgb(255, 170, 127)' self.messageLabel.setStyleSheet('background-color: {};'.format(color)) self.messageLabel.setText(message) self.messageLabel.setToolTip(message) self.timeout = QTimer() self.timeout.timeout.connect(self.clearMessage) self.timeout.setSingleShot(True) self.timeout.start(secs * 1000) def clearMessage(self): self.messageLabel.setStyleSheet('') self.messageLabel.setToolTip('') self.messageLabel.setText('') def getName(self): "return file name of selected" items = self.folderTable.selectedItems() names = [str(i.text()) for i in items] if names: return names[0] # only the first selected file else: return '' def showKeyLen(self, string): "displays a tooltip showing length of key" s = len(string) note = '{:,} character{}'.format(s, '' if s == 1 else 's') tip = QToolTip pos = self.genKeyButton.mapToGlobal(QPoint(0, 0)) if s < self.minKeyLen: note = '<span style="color:#c80000;">{}</span>'.format(note) else: note = '<span style="color:#258f22;">{}</span>'.format(note) tip.showText(pos, note) def lock(self, flag=True): "locks buttons if True" stuff = [ self.openButton, self.encryptButton, self.decryptButton, self.genKeyButton, self.hashButton, self.showKeyCB, self.copyButton, self.keyInput, self.keySizeSB, self.folderTable, ] for i in stuff: i.blockSignals(flag) i.setEnabled(not flag) return def _lerp(self, v1, v2, numPts=10): "linearly interpolate from v1 to v2\nFrom Orthallelous" if len(v1) != len(v2): raise ValueError("different dimensions") D, V, n = [], [], abs(numPts) for i, u in enumerate(v1): D.append(v2[i] - u) for i in range(n + 1): vn = [] for j, u in enumerate(v1): vn.append(u + D[j] / float(n + 2) * i) V.append(tuple(vn)) return V def weeeeeee(self): "party time" self.lock() self.setMessage('Party time!', 2.5) a, b, c = self.encryptPbar, self.decryptPbar, self.hashPbar process, sleep = app.processEvents, time.sleep am, bm, cm = a.minimum(), b.minimum(), c.minimum() ax, bx, cx = a.maximum(), b.maximum(), c.maximum() a.reset() b.reset() c.reset() loops = self._lerp((am, bm, cm), (ax, bx, cx), 100) ivops = loops[::-1] # up and up! for i in range(3): for j, k, l in loops: a.setValue(int(j)) b.setValue(int(k)) c.setValue(int(l)) process() sleep(0.01) a.setValue(ax) b.setValue(bx) c.setValue(cx) sleep(0.25) a.setValue(am) b.setValue(bm) c.setValue(cm) # snake! self.setMessage('Snake time!') self.messageLabel.setStyleSheet('background-color: rgb(127,170,255);') for i in range(2): for j, k, l in loops: a.setValue(int(j)) process() sleep(0.002) process() a.setInvertedAppearance(True) process() for j, k, l in ivops: a.setValue(int(j)) process() sleep(0.002) for j, k, l in loops: b.setValue(int(k)) process() sleep(0.002) process() b.setInvertedAppearance(False) process() for j, k, l in ivops: b.setValue(int(k)) process() sleep(0.002) for j, k, l in loops: c.setValue(int(l)) process() sleep(0.002) process() c.setInvertedAppearance(True) process() for j, k, l in ivops: c.setValue(int(l)) process() sleep(0.002) process() b.setInvertedAppearance(True) process() for j, k, l in loops: b.setValue(int(k)) process() sleep(0.002) process() b.setInvertedAppearance(False) process() for j, k, l in ivops: b.setValue(int(k)) process() sleep(0.002) process() a.setInvertedAppearance(False) b.setInvertedAppearance(True) c.setInvertedAppearance(False) for j, k, l in loops: a.setValue(int(j)) process() sleep(0.002) process() a.setInvertedAppearance(True) process() for j, k, l in ivops: a.setValue(int(j)) process() sleep(0.002) # bars sleep(0.5) self.setMessage('Bars!') process() self.messageLabel.setStyleSheet('background-color: rgb(127,255,170);') for i in range(2): a.setValue(ax) time.sleep(0.65) a.setValue(am) sleep(0.25) process() b.setValue(bx) time.sleep(0.65) b.setValue(bm) sleep(0.25) process() c.setValue(cx) time.sleep(0.65) c.setValue(cm) sleep(0.25) process() b.setValue(bx) time.sleep(0.65) b.setValue(bm) sleep(0.25) process() # okay, enough process() a.setValue(ax) b.setValue(bx) c.setValue(cx) #a.setValue(am); b.setValue(bm); c.setValue(cm) a.setInvertedAppearance(False) b.setInvertedAppearance(True) c.setInvertedAppearance(False) self.lock(False) return def windDown(self, note=None): "silly deload on load" if note is None: note = 'Loading...' self.lock() self.setMessage(note) self.messageLabel.setStyleSheet('background-color: rgb(9, 190, 130);') a, b, c = self.encryptPbar, self.decryptPbar, self.hashPbar am, bm, cm = a.minimum(), b.minimum(), c.minimum() ax, bx, cx = a.maximum(), b.maximum(), c.maximum() a.reset() b.reset() c.reset() loops = self._lerp((ax, bx, cx), (am, bm, cm), 100) for j, k, l in loops: a.setValue(int(j)) b.setValue(int(k)) c.setValue(int(l)) app.processEvents() time.sleep(0.02) a.reset() b.reset() c.reset() self.lock(False) self.clearMessage() def genHash(self, action): "generate hash of selected file and display it" name, t0 = self.getName(), time.perf_counter() # mark what hash was used in the drop-down menu for i in self.hashButton.menu().actions(): if i == action: i.setIconVisibleInMenu(True) else: i.setIconVisibleInMenu(False) if str(action.text()) == 'Party': self.weeeeeee() self.windDown('Winding down...') return if not name: self.setMessage('No file selected') return if not os.path.exists(os.path.join(self.path, name)): self.setMessage('File does not exist') return self.lock() hsh = self.hashFile(os.path.join(self.path, name), getattr(hashlib, str(action.text()))) self.lock(False) #hsh = str(action.text()) + ': ' + hsh self.hashLabel.setText(hsh) self.hashLabel.setToolTip(hsh) self.extraLabel.setText( str(action.text()) + ' hash took ' + self.secs_fmt(time.perf_counter() - t0)) def setCancel(self): "cancel operation" self._requestStop = True def showCancelButton(self, state=False): "show/hide cancel button" self.cancelButton.blockSignals(not state) self.cancelButton.setEnabled(state) if state: self.cancelButton.show() self.keyInput.hide() self.genKeyButton.hide() self.keySizeSB.hide() else: self.cancelButton.hide() self.keyInput.show() self.genKeyButton.show() self.keySizeSB.show() def hashFile(self, fn, hasher): "returns the hash value of a file" hsh, blksize = hasher(), self.blksize fsz, csz = os.path.getsize(fn), 0.0 self.hashPbar.reset() self.showCancelButton(True) prog, title = '(# {:.02%}) {}', self.windowTitle() with open(fn, 'rb') as f: while 1: blk = f.read(blksize) if not blk: break hsh.update(blk) csz += blksize self.hashPbar.setValue(int(round(csz * 100.0 / fsz))) app.processEvents() self.setWindowTitle(prog.format(csz / fsz, title)) if self._requestStop: break self.hashPbar.setValue(self.hashPbar.maximum()) self.setWindowTitle(title) self.showCancelButton(False) if self._requestStop: self.setMessage('Hashing canceled!') self.hashPbar.setValue(self.hashPbar.minimum()) self._requestStop = False return return hsh.hexdigest() def hashKey(self, key, salt=b''): "hashes a key for encrypting/decrypting file" salt = salt.encode() if type(salt) != bytes else salt key = key.encode() if type(key) != bytes else key p = app.processEvents self.setMessage('Key Hashing...', col=(226, 182, 249)) p() key = hashlib.pbkdf2_hmac('sha512', key, salt, 444401) p() self.clearMessage() p() return hashlib.sha3_256(key).digest() # AES requires a 32 char key def encrypt(self): "encrypt selected file with key" name, t0 = self.getName(), time.perf_counter() if not name: self.setMessage('No file selected') return if not os.path.exists(os.path.join(self.path, name)): self.setMessage('File does not exist') return key = str(self.keyInput.text()) if len(key) < self.minKeyLen: self.setMessage(('Key must be at least ' '{} characters long').format(self.minKeyLen)) return self.lock() gn = self.encryptFile(key, os.path.join(self.path, name)) if not gn: self.lock(False) return self.encrypted.append(os.path.basename(gn)) self.lock(False) self.populateTable(self.path) # repopulate folder list bn, tt = os.path.basename(gn), time.perf_counter() - t0 self.setMessage('Encrypted, saved "{}"'.format(bn, 13)) self.extraLabel.setText('Encrypting took ' + self.secs_fmt(tt)) def encryptFile(self, key, fn): "encrypts a file using AES (MODE_GCM)" chars = ''.join(map(chr, range(256))).encode() chk = AES.block_size sample = random.sample iv = bytes(sample(chars, chk * 2)) salt = bytes(sample(chars * 2, 256)) vault = AES.new(self.hashKey(key, salt), AES.MODE_GCM, iv) fsz = os.path.getsize(fn) del key blksize = self.blksize gn = fn + self.ext fne = os.path.basename(fn).encode() fnz = len(fne) if len(fne) % chk: fne += bytes(sample(chars, chk - len(fne) % chk)) csz = 0.0 # current processed value self.encryptPbar.reset() prog, title = '({:.02%}) {}', self.windowTitle() self.showCancelButton(True) with open(fn, 'rb') as src, open(gn, 'wb') as dst: dst.write(bytes([0] * 16)) # spacer for MAC written at end dst.write(iv) dst.write(salt) # store iv, salt # is it safe to store MAC, iv, salt plain right in file? # can't really store them encrypted, # or elsewhere in this model of single file encryption? # can't have another file for the file to lug around # store file size, file name length dst.write(vault.encrypt(struct.pack('<2Q', fsz, fnz))) dst.write(vault.encrypt(fne)) # store filename while 1: dat = src.read(blksize) if not dat: break elif len(dat) % chk: # add padding fil = chk - len(dat) % chk dat += bytes(sample(chars, fil)) dst.write(vault.encrypt(dat)) csz += blksize # show progress self.encryptPbar.setValue(int(round(csz * 100.0 / fsz))) self.setWindowTitle(prog.format(csz / fsz, title)) app.processEvents() if self._requestStop: break if not self._requestStop: stuf = random.randrange(23) # pack in more stuffing fing = b''.join(bytes(sample(chars, 16)) for i in range(stuf)) dst.write(vault.encrypt(fing)) # and for annoyance dst.seek(0) dst.write(vault.digest()) # write MAC self.hashLabel.setText('MAC: ' + vault.hexdigest()) self.encryptPbar.setValue(self.encryptPbar.maximum()) self.setWindowTitle(title) self.showCancelButton(False) if self._requestStop: self.setMessage('Encryption canceled!') self.encryptPbar.setValue(self.encryptPbar.minimum()) self._requestStop = False os.remove(gn) return return gn def decrypt(self): "encrypt selected file with key" name, t0 = self.getName(), time.perf_counter() if not name: self.setMessage('No file selected') return if not os.path.exists(os.path.join(self.path, name)): self.setMessage('File does not exist') return key = str(self.keyInput.text()) if len(key) < self.minKeyLen: self.setMessage(('Key must be at least ' '{} characters long').format(self.minKeyLen)) return self.lock() gn = self.decryptFile(key, os.path.join(self.path, name)) if not gn: self.lock(False) return self.decrypted.append(os.path.basename(gn)) self.lock(False) self.populateTable(self.path) # repopulate folder list bn, tt = os.path.basename(gn), time.perf_counter() - t0 self.setMessage('Decrypted, saved "{}"'.format(bn, 13)) self.extraLabel.setText('Decrypting took ' + self.secs_fmt(tt)) def decryptFile(self, key, fn): "decrypts a file using AES (MODE_GCM)" blksize = self.blksize gn = hashlib.md5(os.path.basename(fn).encode()).hexdigest() gn = os.path.join(self.path, gn) # temporary name if os.path.exists(gn): self.setMessage('file already exists') return self.decryptPbar.reset() csz = 0.0 # current processed value chk, fnsz = AES.block_size, os.path.getsize(fn) prog, title = '({:.02%}) {}', self.windowTitle() try: with open(fn, 'rb') as src, open(gn, 'wb') as dst: # extract iv, salt MAC = src.read(16) iv = src.read(AES.block_size * 2) salt = src.read(256) vault = AES.new(self.hashKey(key, salt), AES.MODE_GCM, iv) self.showCancelButton(True) # extract file size, file name length sizes = src.read(struct.calcsize('<2Q')) fsz, fnz = struct.unpack('<2Q', vault.decrypt(sizes)) # extract filename; round up fnz to nearest chk rnz = fnz if not fnz % chk else fnz + chk - fnz % chk rfn = vault.decrypt(src.read(rnz))[:fnz].decode() self.setMessage('Found "{}"'.format(rfn), 13, (255, 211, 127)) while 1: dat = src.read(blksize) if not dat: break dst.write(vault.decrypt(dat)) csz += blksize # show progress self.decryptPbar.setValue(int(round(csz * 100.0 / fnsz))) self.setWindowTitle(prog.format(1 - (csz / fnsz), title)) app.processEvents() if self._requestStop: break if not self._requestStop: dst.truncate(fsz) # remove padding if not self._requestStop: vault.verify(MAC) self.hashLabel.setText('') except (ValueError, KeyError) as err: os.remove(gn) self.setMessage('Invalid decryption!') self.setWindowTitle(title) self.showCancelButton(False) return except Exception as err: os.remove(gn) self.setMessage('Invalid key or file!') self.setWindowTitle(title) self.showCancelButton(False) return self.decryptPbar.setValue(self.decryptPbar.maximum()) self.setWindowTitle(title) self.showCancelButton(False) if self._requestStop: self.setMessage('Decryption canceled!') self.decryptPbar.setValue(self.decryptPbar.minimum()) self._requestStop = False os.remove(gn) return # restore original file name name, ext = os.path.splitext(rfn) count = 1 fn = os.path.join(self.path, name + ext) while os.path.exists(fn): fn = os.path.join(self.path, name + '_{}'.format(count) + ext) count += 1 os.rename(gn, fn) # restore original name return fn # saved name def copyKeyHash(self, action): "copies either the key or the hash to clipboard" act = str(action.text()).lower() if 'key' in act: txt = str(self.keyInput.text()) elif 'hash' in act: txt = str(self.hashLabel.text()) else: self.setMessage('Invalid copy selection') return if not txt: self.setMessage('Empty text; Nothing to copy') return if 'key' in act: self.setMessage('Key copied to clipboard') elif 'hash' in act: self.setMessage('Hash copied to clipboard') else: self.setMessage('Invalid copy selection') return self.clipboard.clear() self.clipboard.setText(txt) def secs_fmt(self, s): "6357 -> '1h 45m 57s'" Y, D, H, M = 31556952, 86400, 3600, 60 y = int(s // Y) s -= y * Y d = int(s // D) s -= d * D h = int(s // H) s -= h * H m = int(s // M) s -= m * M r = (str(int(s)) if int(s) == s else str(round(s, 3))) + 's' if m: r = str(m) + 'm ' + r if h: r = str(h) + 'h ' + r if d: r = str(d) + 'd ' + r if y: r = str(y) + 'y ' + r return r.strip() def closeEvent(self, event): self.clipboard.clear() def setup(self): "constructs the gui" Fixed = QSizePolicy() MinimumExpanding = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.minKeyLen = 8 self.maxKeyLen = 4096 self.splitter = QSplitter(self) self.splitter.setOrientation(Qt.Horizontal) self.splitter.splitterMoved.connect(self.splitterChanged) # left column self.leftColumn = QWidget() self.vl01 = QVBoxLayout() # left column - first item (0; horizonal layout 0) self.hl00 = QHBoxLayout() self.hl00.setSpacing(5) self.openButton = QPushButton('&Open') self.openButton.setToolTip('Open folder') self.openButton.setMinimumSize(60, 20) self.openButton.setMaximumSize(60, 20) self.openButton.setSizePolicy(Fixed) self.openButton.clicked.connect(self.getFolder) #ico = self.style().standardIcon(QStyle.SP_DirIcon) #self.openButton.setIcon(ico) self.folderLabel = QLabel() self.folderLabel.setMinimumSize(135, 20) self.folderLabel.setMaximumSize(16777215, 20) self.folderLabel.setSizePolicy(MinimumExpanding) self.hl00.insertWidget(0, self.openButton) self.hl00.insertWidget(1, self.folderLabel) # left column - second item (1) self.folderTable = QTableWidget() self.folderTable.setMinimumSize(200, 32) self.folderTable.horizontalHeader().setVisible(False) self.folderTable.horizontalHeader().setStretchLastSection(True) self.folderTable.verticalHeader().setVisible(False) self.folderTable.verticalHeader().setDefaultSectionSize(15) self.folderTable.itemChanged.connect(self.editFileName) # left column - third item (2) self.extraLabel = QLabel() self.extraLabel.setMinimumSize(200, 20) self.extraLabel.setMaximumSize(16777215, 20) self.extraLabel.setSizePolicy(MinimumExpanding) self.extraLabel.setTextInteractionFlags(Qt.LinksAccessibleByMouse) # finalize left column self.vl01.insertLayout(0, self.hl00) self.vl01.insertWidget(1, self.folderTable) self.vl01.insertWidget(2, self.extraLabel) self.leftColumn.setLayout(self.vl01) # right column self.rightColumn = QWidget() self.vl02 = QVBoxLayout() # right column - first item (0) self.messageLabel = QLabel() self.messageLabel.setMinimumSize(290, 20) self.messageLabel.setMaximumSize(16777215, 20) self.messageLabel.setSizePolicy(MinimumExpanding) self.messageLabel.setAlignment(Qt.AlignCenter) # right column - second item (2; horizontal layout 1) self.hl01 = QHBoxLayout() self.hl01.setSpacing(5) self.encryptButton = QPushButton('&Encrypt') #\U0001F512 self.encryptButton.setToolTip('Encrypt selected file') self.encryptButton.setMinimumSize(60, 20) self.encryptButton.setMaximumSize(60, 20) self.encryptButton.setSizePolicy(Fixed) self.encryptButton.clicked.connect(self.encrypt) self.encryptPbar = QProgressBar() self.encryptPbar.setMinimumSize(225, 20) self.encryptPbar.setMaximumSize(16777215, 20) self.encryptPbar.setSizePolicy(MinimumExpanding) self.encryptPbar.setTextVisible(False) palette = self.encryptPbar.palette() # color of progress bar color = QColor(211, 70, 0) palette.setColor(QPalette.Highlight, color) self.encryptPbar.setPalette(palette) self.hl01.insertWidget(0, self.encryptButton) self.hl01.insertWidget(1, self.encryptPbar) # right column - third item (3; horizontal layout 2) self.hl02 = QHBoxLayout() self.hl02.setSpacing(5) self.cancelButton = QPushButton('C&ANCEL') self.cancelButton.setToolTip('Cancels current operation') self.cancelButton.setMinimumSize(70, 24) self.cancelButton.setMaximumSize(70, 24) self.cancelButton.setSizePolicy(Fixed) self.cancelButton.clicked.connect(self.setCancel) font = self.cancelButton.font() font.setBold(True) self.cancelButton.setFont(font) self.cancelButton.blockSignals(True) self.cancelButton.setEnabled(False) self.cancelButton.hide() self._requestStop = False self.keyInput = QLineEdit() self.keyInput.setMinimumSize(225, 20) self.keyInput.setMaximumSize(16777215, 20) self.keyInput.setSizePolicy(MinimumExpanding) self.keyInput.setPlaceholderText('key') self.keyInput.setMaxLength(self.maxKeyLen) self.keyInput.setAlignment(Qt.AlignCenter) self.keyInput.textEdited.connect(self.showKeyLen) self.genKeyButton = QPushButton('&Gen Key') #\U0001F511 self.genKeyButton.setToolTip('Generate a random key') self.genKeyButton.setMinimumSize(60, 20) self.genKeyButton.setMaximumSize(60, 20) self.genKeyButton.setSizePolicy(Fixed) self.genKeyButton.clicked.connect(self.genKey) self.keySizeSB = QSpinBox() self.keySizeSB.setToolTip('Length of key to generate') self.keySizeSB.setRange(32, 1024) self.keySizeSB.setMinimumSize(40, 20) self.keySizeSB.setMaximumSize(40, 20) self.keySizeSB.setSizePolicy(Fixed) self.keySizeSB.setAlignment(Qt.AlignCenter) self.keySizeSB.setButtonSymbols(QSpinBox.NoButtons) self.keySizeSB.setWrapping(True) self.hl02.insertWidget(0, self.cancelButton) self.hl02.insertWidget(1, self.keyInput) self.hl02.insertWidget(2, self.genKeyButton) self.hl02.insertWidget(3, self.keySizeSB) # right column - fourth item (4; horizontal layout 3) self.hl03 = QHBoxLayout() self.hl03.setSpacing(5) self.decryptButton = QPushButton('&Decrypt') #\U0001F513 self.decryptButton.setToolTip('Decrypt selected file') self.decryptButton.setMinimumSize(60, 20) self.decryptButton.setMaximumSize(60, 20) self.decryptButton.setSizePolicy(Fixed) self.decryptButton.clicked.connect(self.decrypt) self.decryptPbar = QProgressBar() self.decryptPbar.setMinimumSize(225, 20) self.decryptPbar.setMaximumSize(16777215, 20) self.decryptPbar.setSizePolicy(MinimumExpanding) self.decryptPbar.setTextVisible(False) self.decryptPbar.setInvertedAppearance(True) palette = self.decryptPbar.palette() # color of progress bar color = QColor(0, 170, 255) palette.setColor(QPalette.Highlight, color) self.decryptPbar.setPalette(palette) self.hl03.insertWidget(0, self.decryptButton) self.hl03.insertWidget(1, self.decryptPbar) # right column - fifth item (7; horizontal layout 4) self.hl04 = QHBoxLayout() self.hl04.setSpacing(5) self.showKeyCB = QCheckBox('&Show Key') self.showKeyCB.setToolTip('Show/Hide key value') self.showKeyCB.setMinimumSize(75, 20) self.showKeyCB.setMaximumSize(75, 20) self.showKeyCB.setSizePolicy(Fixed) self.showKeyCB.clicked.connect(self.showKey) self.showKeyCB.setChecked(True) self.hashPbar = QProgressBar() self.hashPbar.setMinimumSize(150, 20) self.hashPbar.setMaximumSize(16777215, 20) self.hashPbar.setSizePolicy(MinimumExpanding) self.hashPbar.setTextVisible(False) palette = self.hashPbar.palette() # color of progress bar color = QColor(31, 120, 73) palette.setColor(QPalette.Highlight, color) self.hashPbar.setPalette(palette) self.hashButton = QPushButton('&Hash') self.hashButton.setToolTip('Determine file hash') self.hashButton.setMinimumSize(60, 20) self.hashButton.setMaximumSize(60, 20) self.hashButton.setSizePolicy(Fixed) menu = QMenu(self.hashButton) ico = self.style().standardIcon(QStyle.SP_DialogYesButton) for alg in sorted( filter(lambda x: 'shake' not in x, hashlib.algorithms_guaranteed), key=lambda n: (len(n), sorted(hashlib.algorithms_guaranteed).index(n))): menu.addAction( ico, alg ) # drop shake algs as their .hexdigest requires an argument - the rest don't menu.addAction(ico, 'Party') for i in menu.actions(): i.setIconVisibleInMenu(False) self.hashButton.setMenu(menu) menu.triggered.connect(self.genHash) self.hl04.insertWidget(0, self.showKeyCB) self.hl04.insertWidget(1, self.hashPbar) self.hl04.insertWidget(2, self.hashButton) # right column - sixth item (8; horizontal layout 5) self.hl05 = QHBoxLayout() self.hl05.setSpacing(5) self.copyButton = QPushButton('&Copy') #\U0001F4CB self.copyButton.setToolTip('Copy key or hash to clipboard') self.copyButton.setMinimumSize(60, 20) self.copyButton.setMaximumSize(60, 20) self.copyButton.setSizePolicy(Fixed) menu2 = QMenu(self.copyButton) menu2.addAction('Copy Key') menu2.addAction('Copy Hash') self.copyButton.setMenu(menu2) menu2.triggered.connect(self.copyKeyHash) self.hashLabel = QLabel() self.hashLabel.setMinimumSize(225, 20) self.hashLabel.setMaximumSize(16777215, 20) self.hashLabel.setSizePolicy(MinimumExpanding) self.hashLabel.setTextFormat(Qt.PlainText) self.hashLabel.setAlignment(Qt.AlignCenter) self.hashLabel.setTextInteractionFlags(Qt.TextSelectableByMouse) self.hl05.insertWidget(0, self.copyButton) self.hl05.insertWidget(1, self.hashLabel) # finalize right column self.vl02.insertWidget(0, self.messageLabel) self.vl02.insertSpacerItem(1, QSpacerItem(0, 0)) self.vl02.insertLayout(2, self.hl01) self.vl02.insertLayout(3, self.hl02) self.vl02.insertLayout(4, self.hl03) self.vl02.insertSpacerItem(5, QSpacerItem(0, 0)) self.vl02.insertWidget(6, QFrame()) self.vl02.insertLayout(7, self.hl04) self.vl02.insertLayout(8, self.hl05) self.rightColumn.setLayout(self.vl02) # finalize main window self.splitter.insertWidget(0, self.leftColumn) self.splitter.insertWidget(1, self.rightColumn) layout = QHBoxLayout(self) layout.addWidget(self.splitter) self.setLayout(layout) self.setWindowTitle('Simple File Encryptor/Decryptor') self.resize(self.sizeHint())
class QLayerView(QTableView): """ Display the stack of image layers. """ def __init__(self, parent): super(QLayerView, self).__init__(parent) self.img = None # graphic form to show : it # should correspond to the currently selected layer self.currentWin = None # mouse click event self.clicked.connect(self.viewClicked) # set behavior and styles self.setSelectionBehavior(QAbstractItemView.SelectRows) delegate = itemDelegate(parent=self) self.setItemDelegate(delegate) ic1 = QImage(":/images/resources/eye-icon.png") ic2 = QImage(":/images/resources/eye-icon-strike.png") delegate.px1 = QPixmap.fromImage(ic1) delegate.px2 = QPixmap.fromImage(ic2) ic1.invertPixels() ic2.invertPixels() delegate.inv_px1 = QPixmap.fromImage(ic1) delegate.inv_px2 = QPixmap.fromImage(ic2) self.setIconSize(QSize(20, 15)) self.verticalHeader().setMinimumSectionSize(-1) self.verticalHeader().setDefaultSectionSize( self.verticalHeader().minimumSectionSize()) self.horizontalHeader().setMinimumSectionSize(40) self.horizontalHeader().setDefaultSectionSize(40) # drag and drop self.setDragDropMode(QAbstractItemView.DragDrop) self.setDefaultDropAction(Qt.MoveAction) self.setDragDropOverwriteMode(False) self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) ################################ # layer property GUI : # preview, blending mode, opacity, mask color ################################ # Preview option # We should use a QListWidget or a custom optionsWidget # (cf. utils.py) : adding it to QVBoxLayout with mode # Qt.AlignBottom does not work. self.previewOptionBox = QCheckBox('Preview') self.previewOptionBox.setMaximumSize(100, 30) # View/Preview changed event handler def m(state): # state : Qt.Checked Qt.UnChecked if self.img is None: return self.img.useThumb = (state == Qt.Checked) window.updateStatus() self.img.cacheInvalidate() for layer in self.img.layersStack: layer.autoclone = True # auto update cloning layers layer.knitted = False try: QApplication.setOverrideCursor( Qt.WaitCursor ) # TODO 18/04/18 waitcursor is called by applytostack? QApplication.processEvents() # update the whole stack self.img.layersStack[0].applyToStack() self.img.onImageChanged() # TODO added 30/11/18 validate finally: for layer in self.img.layersStack: layer.autoclone = False # reset flags layer.knitted = False QApplication.restoreOverrideCursor() QApplication.processEvents() # window.label.repaint() # TODO removed 30/11/18 replaced by onImageChange above self.previewOptionBox.stateChanged.connect(m) self.previewOptionBox.setChecked(True) # m is not triggered # title titleLabel = QLabel('Layer') titleLabel.setMaximumSize(100, 30) # opacity slider self.opacitySlider = QbLUeSlider(Qt.Horizontal) self.opacitySlider.setStyleSheet( QbLUeSlider.bLueSliderDefaultBWStylesheet) self.opacitySlider.setTickPosition(QSlider.TicksBelow) self.opacitySlider.setRange(0, 100) self.opacitySlider.setSingleStep(1) self.opacitySlider.setSliderPosition(100) self.opacityValue = QLabel() font = self.opacityValue.font() metrics = QFontMetrics(font) w = metrics.width("100 ") h = metrics.height() self.opacityValue.setMinimumSize(w, h) self.opacityValue.setMaximumSize(w, h) self.opacityValue.setText('100 ') # opacity value changed event handler def f1(): self.opacityValue.setText(str('%d ' % self.opacitySlider.value())) # opacity slider released event handler def f2(): try: layer = self.img.getActiveLayer() layer.setOpacity(self.opacitySlider.value()) layer.applyToStack() self.img.onImageChanged() except AttributeError: return self.opacitySlider.valueChanged.connect(f1) self.opacitySlider.sliderReleased.connect(f2) # mask color slider maskSlider = QbLUeSlider(Qt.Horizontal) maskSlider.setStyleSheet(QbLUeSlider.bLueSliderDefaultBWStylesheet) maskSlider.setTickPosition(QSlider.TicksBelow) maskSlider.setRange(0, 100) maskSlider.setSingleStep(1) maskSlider.setSliderPosition(100) self.maskSlider = maskSlider self.maskValue = QLabel() font = self.maskValue.font() metrics = QFontMetrics(font) w = metrics.width("100 ") h = metrics.height() self.maskValue.setMinimumSize(w, h) self.maskValue.setMaximumSize(w, h) self.maskValue.setText('100 ') # mask value changed event handler def g1(): self.maskValue.setText(str('%d ' % self.maskSlider.value())) # mask slider released event handler def g2(): try: layer = self.img.getActiveLayer() layer.setColorMaskOpacity(self.maskSlider.value()) layer.applyToStack() self.img.onImageChanged() except AttributeError: return self.maskSlider.valueChanged.connect(g1) self.maskSlider.sliderReleased.connect(g2) # blending mode combo box compLabel = QLabel() compLabel.setText("Blend") self.compositionModeDict = OrderedDict([ ('Normal', QPainter.CompositionMode_SourceOver), ('Plus', QPainter.CompositionMode_Plus), ('Multiply', QPainter.CompositionMode_Multiply), ('Screen', QPainter.CompositionMode_Screen), ('Overlay', QPainter.CompositionMode_Overlay), ('Darken', QPainter.CompositionMode_Darken), ('Lighten', QPainter.CompositionMode_Lighten), ('Color Dodge', QPainter.CompositionMode_ColorDodge), ('Color Burn', QPainter.CompositionMode_ColorBurn), ('Hard Light', QPainter.CompositionMode_HardLight), ('Soft Light', QPainter.CompositionMode_SoftLight), ('Difference', QPainter.CompositionMode_Difference), ('Exclusion', QPainter.CompositionMode_Exclusion) ]) self.blendingModeCombo = QComboBox() for key in self.compositionModeDict: self.blendingModeCombo.addItem(key, self.compositionModeDict[key]) # combo box item chosen event handler def g(ind): s = self.blendingModeCombo.currentText() try: layer = self.img.getActiveLayer() layer.compositionMode = self.compositionModeDict[str(s)] layer.applyToStack() self.img.onImageChanged() except AttributeError: return self.blendingModeCombo.currentIndexChanged.connect(g) # self.blendingModeCombo.activated.connect(g) # TODO activated changed to currentIndexChanged 08/10/18 validate #layout l = QVBoxLayout() l.setAlignment(Qt.AlignTop) hl0 = QHBoxLayout() hl0.addWidget(titleLabel) hl0.addStretch(1) hl0.addWidget(self.previewOptionBox) l.addLayout(hl0) hl = QHBoxLayout() hl.addWidget(QLabel('Opacity')) hl.addWidget(self.opacityValue) hl.addWidget(self.opacitySlider) l.addLayout(hl) hl1 = QHBoxLayout() hl1.addWidget(QLabel('Mask Color')) hl1.addWidget(self.maskValue) hl1.addWidget(self.maskSlider) l.addLayout(hl1) l.setContentsMargins(0, 0, 10, 0) # left, top, right, bottom hl2 = QHBoxLayout() hl2.addWidget(compLabel) hl2.addWidget(self.blendingModeCombo) l.addLayout(hl2) for layout in [hl, hl1, hl2]: layout.setContentsMargins(5, 0, 0, 0) # this layout must be added to the propertyWidget object loaded from blue.ui : # we postpone it after loading of the main form, in blue.py. self.propertyLayout = l # shortcut actions self.actionDup = QAction('Duplicate layer', None) self.actionDup.setShortcut(QKeySequence(Qt.CTRL + Qt.Key_J)) self.addAction(self.actionDup) def dup(): row = self.selectedIndexes()[0].row() # Stack index index = len(self.img.layersStack) - row - 1 layer = self.img.layersStack[index] if layer.isAdjustLayer(): return # add new layer to stack and set it to active self.img.dupLayer(index=index) # update layer view self.setLayers(self.img) self.actionDup.triggered.connect(dup) self.setWhatsThis("""<b>Layer Stack</b> To <b>toggle the layer visibility</b> click on the Eye icon.<br> To <b>add a mask</b> use the context menu to enable it and paint pixels with the Mask/Unmask tools in the left pane.<br> For <b>color mask<b/b>: <br> green pixels are masked,<br> red pixels are unmasked.<br> Note that upper visible layers slow down mask edition.<br> """) # end of setWhatsThis """ def setEnabled(self, value): # TODO removed 30/11/18 super(QLayerView, self).setEnabled(value) if not self.isEnabled(): self.setStatusTip('Close adjustment form %s to enable Layers' % self.currentWin.windowTitle()) else: self.setStatusTip('') """ def closeAdjustForms(self, delete=False): """ Close all layer forms. If delete is True (default False), the forms and their dock containers are deleted. @param delete: @type delete: boolean """ if self.img is None: return stack = self.img.layersStack for layer in stack: if hasattr(layer, "view"): if layer.view is not None: dock = layer.view if delete: form = dock.widget() # remove back link form.layer = None # QtGui1.window.removeDockWidget(dock) form.setAttribute(Qt.WA_DeleteOnClose) form.close() dock.setAttribute(Qt.WA_DeleteOnClose) dock.close() layer.view = None elif not TABBING: # tabbed forms should not be closed dock.close() if delete: self.currentWin = None gc.collect() def clear(self, delete=True): """ Reset LayerView and clear the back links to image. """ self.closeAdjustForms(delete=delete) self.img = None self.currentWin = None model = layerModel() model.setColumnCount(3) self.setModel(None) def setLayers(self, mImg, delete=False): """ Displays the layer stack of mImg @param mImg: image @type mImg: mImage """ # close open adjustment windows #self.closeAdjustForms() self.clear(delete=delete) mImg.layerView = self # back link to image self.img = weakProxy(mImg) model = layerModel() model.setColumnCount(3) l = len(mImg.layersStack) # dataChanged event handler : enables edition of layer name def f(index1, index2): # index1 and index2 should be equal # only layer name should be editable # dropEvent emit dataChanged when setting item values. f must # return immediately from these calls, as they are possibly made with unconsistent data : # dragged rows are already removed from layersStack # and not yet removed from model. if l != self.model().rowCount(): return # only name is editable if index1.column() != 1: return row = index1.row() stackIndex = l - row - 1 mImg.layersStack[stackIndex].name = index1.data() model.dataChanged.connect(f) for r, lay in enumerate(reversed(mImg.layersStack)): items = [] # col 0 : visibility icon if lay.visible: item_visible = QStandardItem( QIcon(":/images/resources/eye-icon.png"), "") else: item_visible = QStandardItem( QIcon(":/images/resources/eye-icon-strike.png"), "") items.append(item_visible) # col 1 : image icon (for non-adjustment layeronly) and name if len(lay.name) <= 30: name = lay.name else: name = lay.name[:28] + '...' if hasattr(lay, 'inputImg'): item_name = QStandardItem(name) else: # icon with very small dim causes QPainter error # QPixmap.fromImage bug ? smallImg = lay.resize(50 * 50) w, h = smallImg.width(), smallImg.height() if w < h / 5 or h < w / 5: item_name = QStandardItem(name) else: item_name = QStandardItem( QIcon(QPixmap.fromImage(smallImg)), name) # set tool tip to full name item_name.setToolTip(lay.name) items.append(item_name) item_mask = QStandardItem('m') items.append(item_mask) model.appendRow(items) self.setModel(model) self.horizontalHeader().hide() self.verticalHeader().hide() header = self.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # select active layer self.selectRow(len(mImg.layersStack) - 1 - mImg.activeLayerIndex) layerview = mImg.getActiveLayer().view # TODO added 25/11/18 if layerview is not None: layerview.show() if TABBING: layerview.raise_() self.updateForm() for item in self.img.layersStack: if hasattr(item, 'sourceIndex'): combo = item.getGraphicsForm().sourceCombo currentText = combo.currentText() combo.clear() for i, x in enumerate(self.img.layersStack): item.view.widget().sourceCombo.addItem(x.name, i) combo.setCurrentIndex(combo.findText(currentText)) def updateForm(self): activeLayer = self.img.getActiveLayer() if hasattr(activeLayer, 'view'): self.currentWin = activeLayer.view if self.currentWin is not None: self.currentWin.show() self.currentWin.activateWindow() def updateRow(self, row): minInd, maxInd = self.model().index(row, 0), self.model().index(row, 3) self.model().dataChanged.emit(minInd, maxInd) def dropEvent(self, event): """ drop event handler : moving layer @param event: @type event: Qevent """ if event.source() is not self: return # get selected rows and layers rows = set([mi.row() for mi in self.selectedIndexes()]) rStack = self.img.layersStack[::-1] layers = [rStack[i] for i in rows] linked = any(l.group for l in layers) if linked and len(rows) > 1: return # get target row and layer targetRow = self.indexAt(event.pos()).row() targetLayer = rStack[targetRow] if linked: if layers[0].group is not targetLayer.group: return if bool(targetLayer.group) != linked: return # remove target from selection if targetRow in rows: rows.discard(targetRow) rows = sorted(rows) if not rows: return # if target is below last row insert at the last position if targetRow == -1: targetRow = self.model().rowCount() # mapping of src (row) indices to target indices rowMapping = dict() for idx, row in enumerate(rows): if row < targetRow: rowMapping[row] = targetRow + idx else: rowMapping[row + len(rows)] = targetRow + idx # update layerStack using rowMapping # insert None items for _ in range(len(rows)): rStack.insert(targetRow, None) # copy moved items to their final place for srcRow, tgtRow in sorted( rowMapping.items()): # python 3 iteritems->items rStack[tgtRow] = rStack[srcRow] # remove moved items from their initial place for row in reversed(sorted( rowMapping.keys())): # python 3 iterkeys -> keys rStack.pop(row) self.img.layersStack = rStack[::-1] # update model # insert empty rows for _ in range(len(rows)): result = self.model().insertRow(targetRow, QModelIndex()) # copy moved rows to their final place colCount = self.model().columnCount() for srcRow, tgtRow in sorted( rowMapping.items()): # python 3 iteritems->items for col in range(0, colCount): # CAUTION : setItem calls the data changed event handler (cf. setLayers above) self.model().setItem(tgtRow, col, self.model().takeItem(srcRow, col)) # remove moved rows from their initial place and keep track of moved items movedDict = rowMapping.copy() for row in reversed(sorted( rowMapping.keys())): # python 3 iterkeys -> keys self.model().removeRow(row) for s, t in rowMapping.items(): if t > row: movedDict[s] -= 1 ######################################### sanity check for r in range(self.model().rowCount()): id = self.model().index(r, 1) if id.data() != rStack[r].name: raise ValueError('Drop Error') ######################################## # reselect moved rows sel = sorted(movedDict.values()) selectionModel = QtCore.QItemSelectionModel(self.model()) self.setSelectionModel(selectionModel) index1 = self.model().index(sel[0], 1) index2 = self.model().index(sel[-1], 1) itemSelection = QtCore.QItemSelection(index1, index2) self.selectionModel().select( itemSelection, QtCore.QItemSelectionModel.Rows | QtCore.QItemSelectionModel.Select) # multiple selection : display no window if len(sel) > 1: self.currentWin.hide() self.currentWin = None elif len(sel) == 1: self.img.setActiveLayer(len(self.img.layersStack) - sel[0] - 1) # update stack self.img.layersStack[0].applyToStack() self.img.onImageChanged() def select(self, row, col): """ select item in view @param row: @type row: @param col: @type col: @return: @rtype: """ model = self.model() self.viewClicked(model.index(row, col)) def viewClicked(self, clickedIndex): """ Mouse clicked event handler. @param clickedIndex: @type clickedIndex: QModelIndex """ row = clickedIndex.row() rows = set([mi.row() for mi in self.selectedIndexes()]) #multiple selection : go to top of selection m = min(rows) if row != m: clickedIndex = self.model().index(m, clickedIndex.column()) layer = self.img.layersStack[-1 - row] self.actionDup.setEnabled(not layer.isAdjustLayer()) # toggle layer visibility if clickedIndex.column() == 0: # background layer is always visible if row == len(self.img.layersStack) - 1: return #layer.visible = not(layer.visible) layer.setVisible(not (layer.visible)) if self.currentWin is not None: self.currentWin.setVisible(layer.visible) if not layer.visible: self.currentWin = None if layer.tool is not None: layer.tool.setVisible(layer.visible) # update stack if layer.visible: layer.applyToStack() else: i = layer.getUpperVisibleStackIndex() if i >= 0: layer.parentImage.layersStack[i].applyToStack() else: # top layer : update only the presentation layer layer.parentImage.prLayer.execute(l=None, pool=None) self.img.onImageChanged() # update displayed window and active layer activeStackIndex = len(self.img.layersStack) - 1 - row activeLayer = self.img.setActiveLayer(activeStackIndex) if self.currentWin is not None: if not self.currentWin.isFloating(): #self.currentWin.hide() self.currentWin = None if hasattr(self.img.layersStack[activeStackIndex], "view"): self.currentWin = self.img.layersStack[activeStackIndex].view if self.currentWin is not None and activeLayer.visible: self.currentWin.show() self.currentWin.raise_() # make self.currentWin the active window self.currentWin.activateWindow() # update opacity and composition mode for current layer opacity = int(layer.opacity * 100) self.opacityValue.setText(str('%d ' % opacity)) self.opacitySlider.setSliderPosition(opacity) compositionMode = layer.compositionMode ind = self.blendingModeCombo.findData(compositionMode) self.blendingModeCombo.setCurrentIndex(ind) # draw the right rectangle window.label.repaint() def initContextMenu(self): """ return the context menu @return: @rtype: QMenu """ menu = QMenu() menu.actionReset = QAction('Reset To Default', None) menu.actionLoadImage = QAction('Load New Image', None) menu.actionGroupSelection = QAction('Group Selection', None) menu.actionAdd2Group = QAction('Add to Group', None) # Active layer is not in a group or right clicked layer is in a group menu.actionUnGroup = QAction('Ungroup', None) # multiple selections menu.actionMerge = QAction('Merge Lower', None) # merge only adjustment layer with image layer # don't dup adjustment layers menu.actionUnselect = QAction('Unselect All', None) menu.actionRepositionLayer = QAction('Reposition Layer(s)', None) menu.actionColorMaskEnable = QAction('Color Mask', None) menu.actionOpacityMaskEnable = QAction('Opacity Mask', None) menu.actionClippingMaskEnable = QAction('Clipping Mask', None) menu.actionMaskDisable = QAction('Disable Mask', None) menu.actionMaskInvert = QAction('Invert Mask', None) menu.actionMaskReset = QAction('Clear Mask', None) menu.actionMaskCopy = QAction('Copy Mask to Clipboard', None) menu.actionImageCopy = QAction('Copy Image to Clipboard', None) menu.actionMaskPaste = QAction('Paste Mask', None) menu.actionImagePaste = QAction('Paste Image', None) menu.actionMaskDilate = QAction('Dilate Mask', None) menu.actionMaskErode = QAction('Erode Mask', None) menu.actionColorMaskEnable.setCheckable(True) menu.actionOpacityMaskEnable.setCheckable(True) menu.actionClippingMaskEnable.setCheckable(True) menu.actionMaskDisable.setCheckable(True) #################### # Build menu ################### # group/ungroup menu.addAction(menu.actionAdd2Group) menu.addAction(menu.actionGroupSelection) menu.addAction(menu.actionUnGroup) menu.addSeparator() menu.addAction(menu.actionUnselect) menu.addSeparator() menu.addAction(menu.actionRepositionLayer) menu.addSeparator() # layer menu.addAction(menu.actionImageCopy) menu.addAction(menu.actionImagePaste) menu.addSeparator() # mask menu.subMenuEnable = menu.addMenu('Mask...') menu.subMenuEnable.addAction(menu.actionColorMaskEnable) menu.subMenuEnable.addAction(menu.actionOpacityMaskEnable) menu.subMenuEnable.addAction(menu.actionClippingMaskEnable) menu.subMenuEnable.addAction(menu.actionMaskDisable) menu.addAction(menu.actionMaskInvert) menu.addAction(menu.actionMaskReset) menu.addAction(menu.actionMaskCopy) menu.addAction(menu.actionMaskPaste) menu.addAction(menu.actionMaskDilate) menu.addAction(menu.actionMaskErode) menu.addSeparator() # miscellaneous menu.addAction(menu.actionLoadImage) # to link actionDup with a shortcut, # it must be set in __init__ menu.addAction(self.actionDup) menu.addAction(menu.actionMerge) menu.addAction(menu.actionReset) return menu def contextMenuEvent(self, event): """ context menu handler @param event @type event: QContextMenuEvent """ selection = self.selectedIndexes() if not selection: return # get fresh context menu self.cMenu = self.initContextMenu() # get current selection rows = set([mi.row() for mi in selection]) rStack = self.img.layersStack[::-1] layers = [rStack[r] for r in rows] group = [] # TODO added 5/11/18 validate if layers: group = layers[0].group for l in layers: # different groups if l.group and group: if l.group is not group: dlgWarn("Select a single group") return # get current position index = self.indexAt(event.pos()) layerStackIndex = len(self.img.layersStack) - 1 - index.row() layer = self.img.layersStack[layerStackIndex] lowerVisible = self.img.layersStack[layer.getLowerVisibleStackIndex()] lower = self.img.layersStack[layerStackIndex - 1] # case index == 0 doesn't matter # toggle actions self.cMenu.actionGroupSelection.setEnabled(not (len(rows) < 2 or any( l.group for l in layers))) self.cMenu.actionAdd2Group.setEnabled(not (group or layer.group)) self.cMenu.actionUnGroup.setEnabled(bool(layer.group)) self.cMenu.actionMerge.setEnabled(not ( hasattr(layer, 'inputImg') or hasattr(lowerVisible, 'inputImg'))) self.actionDup.setEnabled(not layer.isAdjustLayer()) self.cMenu.actionColorMaskEnable.setChecked(layer.maskIsSelected and layer.maskIsEnabled) self.cMenu.actionOpacityMaskEnable.setChecked( (not layer.maskIsSelected) and layer.maskIsEnabled) self.cMenu.actionClippingMaskEnable.setChecked( layer.isClipping and (layer.maskIsSelected or layer.maskIsEnabled)) self.cMenu.actionMaskDisable.setChecked(not ( layer.isClipping or layer.maskIsSelected or layer.maskIsEnabled)) self.cMenu.actionUnselect.setEnabled(layer.rect is None) self.cMenu.subMenuEnable.setEnabled(len(rows) == 1) self.cMenu.actionMaskPaste.setEnabled( not QApplication.clipboard().image().isNull()) self.cMenu.actionImagePaste.setEnabled( not QApplication.clipboard().image().isNull()) # Event handlers def f(): self.opacitySlider.show() def unselectAll(): layer.rect = None def RepositionLayer(): layer.xOffset, layer.yOffset = 0, 0 layer.Zoom_coeff = 1.0 layer.AltZoom_coeff = 1.0 layer.xAltOffset, layer.yAltOffset = 0, 0 layer.updatePixmap() self.img.onImageChanged() def loadImage(): return # TODO 26/06/18 action to remove from menu? replaced by new image layer filename = openDlg(window) img = QImage(filename) layer.thumb = None layer.setImage(img) def add2Group(): layer.group = group layer.mask = group[0].mask layer.maskIsEnabled = True layer.maskIsSelected = True def groupSelection(): layers = [rStack[i] for i in sorted(rows)] if any(l.group for l in layers): dlgWarn("Some layers are already grouped. Ungroup first") return mask = layers[0].mask for l in layers: l.group = layers l.mask = mask l.maskIsEnabled = True l.maskIsSelected = False def unGroup(): group = layer.group.copy() for l in group: l.unlinkMask() def merge(): layer.merge_with_layer_immediately_below() def testUpperVisibility(): pos = self.img.getStackIndex(layer) upperVisible = False for i in range(len(self.img.layersStack) - pos - 1): if self.img.layersStack[pos + 1 + i].visible: upperVisible = True break if upperVisible: dlgWarn("Upper visible layers slow down mask edition") return True return False def colorMaskEnable(): testUpperVisibility() layer.maskIsEnabled = True layer.maskIsSelected = True layer.applyToStack() self.img.onImageChanged() def opacityMaskEnable(): testUpperVisibility() layer.maskIsEnabled = True layer.maskIsSelected = False layer.applyToStack() self.img.onImageChanged() def clippingMaskEnable(): layer.maskIsEnabled = True layer.maskIsSelected = False layer.isClipping = True layer.applyToStack() self.img.onImageChanged() def maskDisable(): layer.maskIsEnabled = False layer.maskIsSelected = False layer.isClipping = False # TODO added 28/11/18 layer.applyToStack() self.img.onImageChanged() def maskInvert(): layer.invertMask() # update mask stack layer.applyToStack() #for l in self.img.layersStack: #l.updatePixmap(maskOnly=True) self.img.onImageChanged() def maskReset(): layer.resetMask() # update mask stack for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.onImageChanged() def maskCopy(): QApplication.clipboard().setImage(layer.mask) def imageCopy(): QApplication.clipboard().setImage(layer.getCurrentMaskedImage()) def maskPaste(): """ Pastes clipboard to mask and updates the stack. The clipboard image is scaled if its size does not match the size of the mask """ cb = QApplication.clipboard() if not cb.image().isNull(): img = cb.image() if img.size() == layer.mask.size(): layer.mask = img else: layer.mask = img.scaled(layer.mask.size()) layer.applyToStack() self.img.onImageChanged() def imagePaste(): """ Pastes clipboard to mask and updates the stack. The clipboard image is scaled if its size does not match the size of the mask """ cb = QApplication.clipboard() if not cb.image().isNull(): srcImg = cb.image() if srcImg.size() == layer.size(): layer.setImage(srcImg) else: layer.setImage(srcImg.scaled(layer.size())) layer.applyToStack() self.img.onImageChanged() def maskDilate(): kernel = np.ones((5, 5), np.uint8) buf = QImageBuffer(layer.mask) # CAUTION erode decreases values (min filter), so it extends the masked part of the image buf[:, :, 2] = cv2.erode(buf[:, :, 2], kernel, iterations=1) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.onImageChanged() def maskErode(): kernel = np.ones((5, 5), np.uint8) buf = QImageBuffer(layer.mask) # CAUTION dilate increases values (max filter), so it reduces the masked part of the image buf[:, :, 2] = cv2.dilate(buf[:, :, 2], kernel, iterations=1) for l in self.img.layersStack: l.updatePixmap(maskOnly=True) self.img.onImageChanged() def layerReset(): view = layer.getGraphicsForm() if hasattr(view, 'reset'): view.reset() self.cMenu.actionRepositionLayer.triggered.connect(RepositionLayer) self.cMenu.actionUnselect.triggered.connect(unselectAll) self.cMenu.actionLoadImage.triggered.connect(loadImage) self.cMenu.actionAdd2Group.triggered.connect(add2Group) self.cMenu.actionGroupSelection.triggered.connect(groupSelection) self.cMenu.actionUnGroup.triggered.connect(unGroup) self.cMenu.actionMerge.triggered.connect(merge) self.cMenu.actionColorMaskEnable.triggered.connect(colorMaskEnable) self.cMenu.actionOpacityMaskEnable.triggered.connect(opacityMaskEnable) self.cMenu.actionClippingMaskEnable.triggered.connect( clippingMaskEnable) self.cMenu.actionMaskDisable.triggered.connect(maskDisable) self.cMenu.actionMaskInvert.triggered.connect(maskInvert) self.cMenu.actionMaskReset.triggered.connect(maskReset) self.cMenu.actionMaskCopy.triggered.connect(maskCopy) self.cMenu.actionMaskPaste.triggered.connect(maskPaste) self.cMenu.actionImageCopy.triggered.connect(imageCopy) self.cMenu.actionImagePaste.triggered.connect(imagePaste) self.cMenu.actionMaskDilate.triggered.connect(maskDilate) self.cMenu.actionMaskErode.triggered.connect(maskErode) self.cMenu.actionReset.triggered.connect(layerReset) self.cMenu.exec_(event.globalPos()) # update table for row in rows: self.updateRow(row)