class ModFileTreeModel_QUndo(ModFileTreeModel): """ An extension of the ModFileTreeModel that only handles undo events; everything else is delegated back to the base class """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._stack = QUndoStack(self) @property def undostack(self): return self._stack @property def has_unsaved_changes(self): return not self._stack.isClean() def setMod(self, mod_entry): if mod_entry is self.mod: return self.save() self._stack.clear() super().setMod(mod_entry) # todo: show dialog box asking if the user would like to save # unsaved changes; have checkbox to allow 'remembering' the answer def setData(self, index, value, role=Qt_CheckStateRole): """Simply a wrapper around the base setData() that only pushes a qundocommand if setData would have returned True""" # see if the setData() command should succeed if super().setData(index, value, role): # if it does, the model should have created and queued a # command; try to to grab it and push it to the undostack try: self._stack.push(self.dequeue_command()) except IndexError as e: # if there was no command in the queue...well, what happened? self.LOGGER.error("No command queued") self.LOGGER.exception(e) # pass return True return False def save(self): # if we have any changes to apply: if not self._stack.isClean(): super().save() self._stack.setClean() def revert_changes(self): # revert to 'clean' state self._stack.setIndex(self._stack.cleanIndex())
class MapDocument(QObject): fileNameChanged = pyqtSignal(str, str) modifiedChanged = pyqtSignal() saved = pyqtSignal() ## # Emitted when the selected tile region changes. Sends the currently # selected region and the previously selected region. ## selectedAreaChanged = pyqtSignal(QRegion, QRegion) ## # Emitted when the list of selected objects changes. ## selectedObjectsChanged = pyqtSignal() ## # Emitted when the list of selected tiles from the dock changes. ## selectedTilesChanged = pyqtSignal() currentObjectChanged = pyqtSignal(list) ## # Emitted when the map size or its tile size changes. ## mapChanged = pyqtSignal() layerAdded = pyqtSignal(int) layerAboutToBeRemoved = pyqtSignal(int) layerRenamed = pyqtSignal(int) layerRemoved = pyqtSignal(int) layerChanged = pyqtSignal(int) ## # Emitted after a new layer was added and the name should be edited. # Applies to the current layer. ## editLayerNameRequested = pyqtSignal() editCurrentObject = pyqtSignal() ## # Emitted when the current layer index changes. ## currentLayerIndexChanged = pyqtSignal(int) ## # Emitted when a certain region of the map changes. The region is given in # tile coordinates. ## regionChanged = pyqtSignal(QRegion, Layer) ## # Emitted when a certain region of the map was edited by user input. # The region is given in tile coordinates. # If multiple layers have been edited, multiple signals will be emitted. ## regionEdited = pyqtSignal(QRegion, Layer) tileLayerDrawMarginsChanged = pyqtSignal(TileLayer) tileTerrainChanged = pyqtSignal(QList) tileProbabilityChanged = pyqtSignal(Tile) tileObjectGroupChanged = pyqtSignal(Tile) tileAnimationChanged = pyqtSignal(Tile) objectGroupChanged = pyqtSignal(ObjectGroup) imageLayerChanged = pyqtSignal(ImageLayer) tilesetAboutToBeAdded = pyqtSignal(int) tilesetAdded = pyqtSignal(int, Tileset) tilesetAboutToBeRemoved = pyqtSignal(int) tilesetRemoved = pyqtSignal(Tileset) tilesetMoved = pyqtSignal(int, int) tilesetFileNameChanged = pyqtSignal(Tileset) tilesetNameChanged = pyqtSignal(Tileset) tilesetTileOffsetChanged = pyqtSignal(Tileset) tilesetChanged = pyqtSignal(Tileset) objectsAdded = pyqtSignal(QList) objectsInserted = pyqtSignal(ObjectGroup, int, int) objectsRemoved = pyqtSignal(QList) objectsChanged = pyqtSignal(QList) objectsIndexChanged = pyqtSignal(ObjectGroup, int, int) propertyAdded = pyqtSignal(Object, str) propertyRemoved = pyqtSignal(Object, str) propertyChanged = pyqtSignal(Object, str) propertiesChanged = pyqtSignal(Object) ## # Constructs a map document around the given map. The map document takes # ownership of the map. ## def __init__(self, map, fileName=QString()): super().__init__() ## # The filename of a plugin is unique. So it can be used to determine # the right plugin to be used for saving or reloading the map. # The nameFilter of a plugin can not be used, since it's translatable. # The filename of a plugin must not change while maps are open using this # plugin. ## self.mReaderFormat = None self.mWriterFormat = None self.mExportFormat = None self.mSelectedArea = QRegion() self.mSelectedObjects = QList() self.mSelectedTiles = QList() self.mCurrentLayerIndex = 0 self.mLastSaved = QDateTime() self.mLastExportFileName = '' self.mFileName = fileName self.mMap = map self.mLayerModel = LayerModel(self) self.mCurrentObject = map ## Current properties object. ## self.mRenderer = None self.mMapObjectModel = MapObjectModel(self) self.mTerrainModel = TerrainModel(self, self) self.mUndoStack = QUndoStack(self) self.createRenderer() if (map.layerCount() == 0): _x = -1 else: _x = 0 self.mCurrentLayerIndex = _x self.mLayerModel.setMapDocument(self) # Forward signals emitted from the layer model self.mLayerModel.layerAdded.connect(self.onLayerAdded) self.mLayerModel.layerAboutToBeRemoved.connect( self.onLayerAboutToBeRemoved) self.mLayerModel.layerRemoved.connect(self.onLayerRemoved) self.mLayerModel.layerChanged.connect(self.layerChanged) # Forward signals emitted from the map object model self.mMapObjectModel.setMapDocument(self) self.mMapObjectModel.objectsAdded.connect(self.objectsAdded) self.mMapObjectModel.objectsChanged.connect(self.objectsChanged) self.mMapObjectModel.objectsRemoved.connect(self.onObjectsRemoved) self.mMapObjectModel.rowsInserted.connect( self.onMapObjectModelRowsInserted) self.mMapObjectModel.rowsRemoved.connect( self.onMapObjectModelRowsInsertedOrRemoved) self.mMapObjectModel.rowsMoved.connect(self.onObjectsMoved) self.mTerrainModel.terrainRemoved.connect(self.onTerrainRemoved) self.mUndoStack.cleanChanged.connect(self.modifiedChanged) # Register tileset references tilesetManager = TilesetManager.instance() tilesetManager.addReferences(self.mMap.tilesets()) ## # Destructor. ## def __del__(self): # Unregister tileset references tilesetManager = TilesetManager.instance() tilesetManager.removeReferences(self.mMap.tilesets()) del self.mRenderer del self.mMap ## # Saves the map to its current file name. Returns whether or not the file # was saved successfully. If not, <i>error</i> will be set to the error # message if it is not 0. ## def save(self, *args): l = len(args) if l == 0: args = ('') if l == 1: arg = args[0] file = QFileInfo(arg) if not file.isFile(): fileName = self.fileName() error = args[0] else: fileName = arg error = '' return self.save(fileName, error) if l == 2: ## # Saves the map to the file at \a fileName. Returns whether or not the # file was saved successfully. If not, <i>error</i> will be set to the # error message if it is not 0. # # If the save was successful, the file name of this document will be set # to \a fileName. # # The map format will be the same as this map was opened with. ## fileName, error = args mapFormat = self.mWriterFormat tmxMapFormat = TmxMapFormat() if (not mapFormat): mapFormat = tmxMapFormat if (not mapFormat.write(self.map(), fileName)): if (error): error = mapFormat.errorString() return False self.undoStack().setClean() self.setFileName(fileName) self.mLastSaved = QFileInfo(fileName).lastModified() self.saved.emit() return True ## # Loads a map and returns a MapDocument instance on success. Returns 0 # on error and sets the \a error message. ## def load(fileName, mapFormat=None): error = '' tmxMapFormat = TmxMapFormat() if (not mapFormat and not tmxMapFormat.supportsFile(fileName)): # Try to find a plugin that implements support for this format formats = PluginManager.objects() for format in formats: if (format.supportsFile(fileName)): mapFormat = format break map = None errorString = '' if mapFormat: map = mapFormat.read(fileName) errorString = mapFormat.errorString() else: map = tmxMapFormat.read(fileName) errorString = tmxMapFormat.errorString() if (not map): error = errorString return None, error mapDocument = MapDocument(map, fileName) if mapFormat: mapDocument.setReaderFormat(mapFormat) if mapFormat.hasCapabilities(MapFormat.Write): mapDocument.setWriterFormat(mapFormat) return mapDocument, error def fileName(self): return self.mFileName def lastExportFileName(self): return self.mLastExportFileName def setLastExportFileName(self, fileName): self.mLastExportFileName = fileName def readerFormat(self): return self.mReaderFormat def setReaderFormat(self, format): self.mReaderFormat = format def writerFormat(self): return self.mWriterFormat def setWriterFormat(self, format): self.mWriterFormat = format def exportFormat(self): return self.mExportFormat def setExportFormat(self, format): self.mExportFormat = format ## # Returns the name with which to display this map. It is the file name without # its path, or 'untitled.tmx' when the map has no file name. ## def displayName(self): displayName = QFileInfo(self.mFileName).fileName() if len(displayName) == 0: displayName = self.tr("untitled.tmx") return displayName ## # Returns whether the map has unsaved changes. ## def isModified(self): return not self.mUndoStack.isClean() def lastSaved(self): return self.mLastSaved ## # Returns the map instance. Be aware that directly modifying the map will # not allow the GUI to update itself appropriately. ## def map(self): return self.mMap ## # Sets the current layer to the given index. ## def setCurrentLayerIndex(self, index): changed = self.mCurrentLayerIndex != index self.mCurrentLayerIndex = index ## This function always sends the following signal, even if the index # didn't actually change. This is because the selected index in the layer # table view might be out of date anyway, and would otherwise not be # properly updated. # # This problem happens due to the selection model not sending signals # about changes to its current index when it is due to insertion/removal # of other items. The selected item doesn't change in that case, but our # layer index does. ## self.currentLayerIndexChanged.emit(self.mCurrentLayerIndex) if (changed and self.mCurrentLayerIndex != -1): self.setCurrentObject(self.currentLayer()) ## # Returns the index of the currently selected layer. Returns -1 if no # layer is currently selected. ## def currentLayerIndex(self): return self.mCurrentLayerIndex ## # Returns the currently selected layer, or 0 if no layer is currently # selected. ## def currentLayer(self): if (self.mCurrentLayerIndex == -1): return None return self.mMap.layerAt(self.mCurrentLayerIndex) ## # Resize this map to the given \a size, while at the same time shifting # the contents by \a offset. ## def resizeMap(self, size, offset): movedSelection = self.mSelectedArea.translated(offset) newArea = QRect(-offset, size) visibleArea = self.mRenderer.boundingRect(newArea) origin = self.mRenderer.tileToPixelCoords_(QPointF()) newOrigin = self.mRenderer.tileToPixelCoords_(-offset) pixelOffset = origin - newOrigin # Resize the map and each layer self.mUndoStack.beginMacro(self.tr("Resize Map")) for i in range(self.mMap.layerCount()): layer = self.mMap.layerAt(i) x = layer.layerType() if x == Layer.TileLayerType: tileLayer = layer self.mUndoStack.push( ResizeTileLayer(self, tileLayer, size, offset)) elif x == Layer.ObjectGroupType: objectGroup = layer # Remove objects that will fall outside of the map for o in objectGroup.objects(): if (not visibleIn(visibleArea, o, self.mRenderer)): self.mUndoStack.push(RemoveMapObject(self, o)) else: oldPos = o.position() newPos = oldPos + pixelOffset self.mUndoStack.push( MoveMapObject(self, newPos, oldPos)) elif x == Layer.ImageLayerType: # Currently not adjusted when resizing the map break self.mUndoStack.push(ResizeMap(self, size)) self.mUndoStack.push(ChangeSelectedArea(self, movedSelection)) self.mUndoStack.endMacro() # TODO: Handle layers that don't match the map size correctly ## # Offsets the layers at \a layerIndexes by \a offset, within \a bounds, # and optionally wraps on the X or Y axis. ## def offsetMap(self, layerIndexes, offset, bounds, wrapX, wrapY): if (layerIndexes.empty()): return if (layerIndexes.size() == 1): self.mUndoStack.push( OffsetLayer(self, layerIndexes.first(), offset, bounds, wrapX, wrapY)) else: self.mUndoStack.beginMacro(self.tr("Offset Map")) for layerIndex in layerIndexes: self.mUndoStack.push( OffsetLayer(self, layerIndex, offset, bounds, wrapX, wrapY)) self.mUndoStack.endMacro() ## # Flips the selected objects in the given \a direction. ## def flipSelectedObjects(self, direction): if (self.mSelectedObjects.isEmpty()): return self.mUndoStack.push( FlipMapObjects(self, self.mSelectedObjects, direction)) ## # Rotates the selected objects. ## def rotateSelectedObjects(self, direction): if (self.mSelectedObjects.isEmpty()): return self.mUndoStack.beginMacro( self.tr("Rotate %n Object(s)", "", self.mSelectedObjects.size())) # TODO: Rotate them properly as a group for mapObject in self.mSelectedObjects: oldRotation = mapObject.rotation() newRotation = oldRotation if (direction == RotateDirection.RotateLeft): newRotation -= 90 if (newRotation < -180): newRotation += 360 else: newRotation += 90 if (newRotation > 180): newRotation -= 360 self.mUndoStack.push( RotateMapObject(self, mapObject, newRotation, oldRotation)) self.mUndoStack.endMacro() ## # Adds a layer of the given type to the top of the layer stack. After adding # the new layer, emits editLayerNameRequested(). ## def addLayer(self, layerType): layer = None name = QString() x = layerType if x == Layer.TileLayerType: name = self.tr("Tile Layer %d" % (self.mMap.tileLayerCount() + 1)) layer = TileLayer(name, 0, 0, self.mMap.width(), self.mMap.height()) elif x == Layer.ObjectGroupType: name = self.tr("Object Layer %d" % (self.mMap.objectGroupCount() + 1)) layer = ObjectGroup(name, 0, 0, self.mMap.width(), self.mMap.height()) elif x == Layer.ImageLayerType: name = self.tr("Image Layer %d" % (self.mMap.imageLayerCount() + 1)) layer = ImageLayer(name, 0, 0, self.mMap.width(), self.mMap.height()) index = self.mMap.layerCount() self.mUndoStack.push(AddLayer(self, index, layer)) self.setCurrentLayerIndex(index) self.editLayerNameRequested.emit() ## # Duplicates the currently selected layer. ## def duplicateLayer(self): if (self.mCurrentLayerIndex == -1): return duplicate = self.mMap.layerAt(self.mCurrentLayerIndex).clone() duplicate.setName(self.tr("Copy of %s" % duplicate.name())) index = self.mCurrentLayerIndex + 1 cmd = AddLayer(self, index, duplicate) cmd.setText(self.tr("Duplicate Layer")) self.mUndoStack.push(cmd) self.setCurrentLayerIndex(index) ## # Merges the currently selected layer with the layer below. This only works # when the layers can be merged. # # \see Layer.canMergeWith ## def mergeLayerDown(self): if (self.mCurrentLayerIndex < 1): return upperLayer = self.mMap.layerAt(self.mCurrentLayerIndex) lowerLayer = self.mMap.layerAt(self.mCurrentLayerIndex - 1) if (not lowerLayer.canMergeWith(upperLayer)): return merged = lowerLayer.mergedWith(upperLayer) self.mUndoStack.beginMacro(self.tr("Merge Layer Down")) self.mUndoStack.push( AddLayer(self, self.mCurrentLayerIndex - 1, merged)) self.mUndoStack.push(RemoveLayer(self, self.mCurrentLayerIndex)) self.mUndoStack.push(RemoveLayer(self, self.mCurrentLayerIndex)) self.mUndoStack.endMacro() ## # Moves the given layer up. Does nothing when no valid layer index is # given. ## def moveLayerUp(self, index): if index < 0 or index >= self.mMap.layerCount() - 1: return self.mUndoStack.push(MoveLayer(self, index, MoveLayer.Up)) ## # Moves the given layer down. Does nothing when no valid layer index is # given. ## def moveLayerDown(self, index): if index < 1 or index >= self.mMap.layerCount(): return self.mUndoStack.push(MoveLayer(self, index, MoveLayer.Down)) ## # Removes the given layer. ## def removeLayer(self, index): if index < 0 or index >= self.mMap.layerCount(): return self.mUndoStack.push(RemoveLayer(self, index)) ## # Show or hide all other layers except the layer at the given index. # If any other layer is visible then all layers will be hidden, otherwise # the layers will be shown. ## def toggleOtherLayers(self, index): self.mLayerModel.toggleOtherLayers(index) ## # Adds a tileset to this map at the given \a index. Emits the appropriate # signal. ## def insertTileset(self, index, tileset): self.tilesetAboutToBeAdded.emit(index) self.mMap.insertTileset(index, tileset) tilesetManager = TilesetManager.instance() tilesetManager.addReference(tileset) self.tilesetAdded.emit(index, tileset) ## # Removes the tileset at the given \a index from this map. Emits the # appropriate signal. # # \warning Does not make sure that any references to tiles in the removed # tileset are cleared. ## def removeTilesetAt(self, index): self.tilesetAboutToBeRemoved.emit(index) tileset = self.mMap.tilesets().at(index) if (tileset == self.mCurrentObject or isFromTileset(self.mCurrentObject, tileset)): self.setCurrentObject(None) self.mMap.removeTilesetAt(index) self.tilesetRemoved.emit(tileset) tilesetManager = TilesetManager.instance() tilesetManager.removeReference(tileset) def moveTileset(self, _from, to): if (_from == to): return tileset = self.mMap.tilesets().at(_from) self.mMap.removeTilesetAt(_from) self.mMap.insertTileset(to, tileset) self.tilesetMoved.emit(_from, to) def setTilesetFileName(self, tileset, fileName): tileset.setFileName(fileName) self.tilesetFileNameChanged.emit(tileset) def setTilesetName(self, tileset, name): tileset.setName(name) self.tilesetNameChanged.emit(tileset) def setTilesetTileOffset(self, tileset, tileOffset): tileset.setTileOffset(tileOffset) self.mMap.recomputeDrawMargins() self.tilesetTileOffsetChanged.emit(tileset) def duplicateObjects(self, objects): if (objects.isEmpty()): return self.mUndoStack.beginMacro( self.tr("Duplicate %n Object(s)", "", objects.size())) clones = QList() for mapObject in objects: clone = mapObject.clone() clones.append(clone) self.mUndoStack.push( AddMapObject(self, mapObject.objectGroup(), clone)) self.mUndoStack.endMacro() self.setSelectedObjects(clones) def removeObjects(self, objects): if (objects.isEmpty()): return self.mUndoStack.beginMacro( self.tr("Remove %n Object(s)", "", objects.size())) for mapObject in objects: self.mUndoStack.push(RemoveMapObject(self, mapObject)) self.mUndoStack.endMacro() def moveObjectsToGroup(self, objects, objectGroup): if (objects.isEmpty()): return self.mUndoStack.beginMacro( self.tr("Move %n Object(s) to Layer", "", objects.size())) for mapObject in objects: if (mapObject.objectGroup() == objectGroup): continue self.mUndoStack.push( MoveMapObjectToGroup(self, mapObject, objectGroup)) self.mUndoStack.endMacro() def setProperty(self, object, name, value): hadProperty = object.hasProperty(name) object.setProperty(name, value) if (hadProperty): self.propertyChanged.emit(object, name) else: self.propertyAdded.emit(object, name) def setProperties(self, object, properties): object.setProperties(properties) self.propertiesChanged.emit(object) def removeProperty(self, object, name): object.removeProperty(name) self.propertyRemoved.emit(object, name) ## # Returns the layer model. Can be used to modify the layer stack of the # map, and to display the layer stack in a view. ## def layerModel(self): return self.mLayerModel def mapObjectModel(self): return self.mMapObjectModel def terrainModel(self): return self.mTerrainModel ## # Returns the map renderer. ## def renderer(self): return self.mRenderer ## # Creates the map renderer. Should be called after changing the map # orientation. ## def createRenderer(self): if (self.mRenderer): del self.mRenderer x = self.mMap.orientation() if x == Map.Orientation.Isometric: self.mRenderer = IsometricRenderer(self.mMap) elif x == Map.Orientation.Staggered: self.mRenderer = StaggeredRenderer(self.mMap) elif x == Map.Orientation.Hexagonal: self.mRenderer = HexagonalRenderer(self.mMap) else: self.mRenderer = OrthogonalRenderer(self.mMap) ## # Returns the undo stack of this map document. Should be used to push any # commands on that modify the map. ## def undoStack(self): return self.mUndoStack ## # Returns the selected area of tiles. ## def selectedArea(self): return QRegion(self.mSelectedArea) ## # Sets the selected area of tiles. ## def setSelectedArea(self, selection): if (self.mSelectedArea != selection): oldSelectedArea = self.mSelectedArea self.mSelectedArea = selection self.selectedAreaChanged.emit(self.mSelectedArea, oldSelectedArea) ## # Returns the list of selected objects. ## def selectedObjects(self): return self.mSelectedObjects ## # Sets the list of selected objects, emitting the selectedObjectsChanged # signal. ## def setSelectedObjects(self, selectedObjects): if selectedObjects.nequal(self.mSelectedObjects): self.mSelectedObjects = selectedObjects self.selectedObjectsChanged.emit() if (selectedObjects.size() == 1): self.setCurrentObject(selectedObjects.first()) ## # Returns the list of selected tiles. ## def selectedTiles(self): return self.mSelectedTiles def setSelectedTiles(self, selectedTiles): self.mSelectedTiles = selectedTiles self.selectedTilesChanged.emit() def currentObject(self): return self.mCurrentObject def setCurrentObject(self, object): if (object == self.mCurrentObject): return self.mCurrentObject = object self.currentObjectChanged.emit([object]) def currentObjects(self): objects = QList() if (self.mCurrentObject): if (self.mCurrentObject.typeId() == Object.MapObjectType and not self.mSelectedObjects.isEmpty()): for mapObj in self.mSelectedObjects: objects.append(mapObj) elif (self.mCurrentObject.typeId() == Object.TileType and not self.mSelectedTiles.isEmpty()): for tile in self.mSelectedTiles: objects.append(tile) else: objects.append(self.mCurrentObject) return objects def unifyTilesets(self, *args): l = len(args) if l == 1: ## # Makes sure the all tilesets which are used at the given \a map will be # present in the map document. # # To reach the aim, all similar tilesets will be replaced by the version # in the current map document and all missing tilesets will be added to # the current map document. # # \warning This method assumes that the tilesets in \a map are managed by # the TilesetManager! ## map = args[0] undoCommands = QList() existingTilesets = self.mMap.tilesets() tilesetManager = TilesetManager.instance() # Add tilesets that are not yet part of this map for tileset in map.tilesets(): if (existingTilesets.contains(tileset)): continue replacement = tileset.findSimilarTileset(existingTilesets) if (not replacement): undoCommands.append(AddTileset(self, tileset)) continue # Merge the tile properties sharedTileCount = min(tileset.tileCount(), replacement.tileCount()) for i in range(sharedTileCount): replacementTile = replacement.tileAt(i) properties = replacementTile.properties() properties.merge(tileset.tileAt(i).properties()) undoCommands.append( ChangeProperties(self, self.tr("Tile"), replacementTile, properties)) map.replaceTileset(tileset, replacement) tilesetManager.addReference(replacement) tilesetManager.removeReference(tileset) if (not undoCommands.isEmpty()): self.mUndoStack.beginMacro(self.tr("Tileset Changes")) for command in undoCommands: self.mUndoStack.push(command) self.mUndoStack.endMacro() elif l == 2: map, missingTilesets = args existingTilesets = self.mMap.tilesets() tilesetManager = TilesetManager.instance() for tileset in map.tilesets(): # tileset already added if existingTilesets.contains(tileset): continue replacement = tileset.findSimilarTileset(existingTilesets) # tileset not present and no replacement tileset found if not replacement: if not missingTilesets.contains(tileset): missingTilesets.append(tileset) continue # replacement tileset found, change given map map.replaceTileset(tileset, replacement) tilesetManager.addReference(replacement) tilesetManager.removeReference(tileset) ## # Emits the map changed signal. This signal should be emitted after changing # the map size or its tile size. ## def emitMapChanged(self): self.mapChanged.emit() ## # Emits the region changed signal for the specified region. The region # should be in tile coordinates. This method is used by the TilePainter. ## def emitRegionChanged(self, region, layer): self.regionChanged.emit(region, layer) ## # Emits the region edited signal for the specified region and tile layer. # The region should be in tile coordinates. This should be called from # all map document changing classes which are triggered by user input. ## def emitRegionEdited(self, region, layer): self.regionEdited.emit(region, layer) def emitTileLayerDrawMarginsChanged(self, layer): self.tileLayerDrawMarginsChanged.emit(layer) ## # Emits the tileset changed signal. This signal is currently used when adding # or removing tiles from a tileset. # # @todo Emit more specific signals. ## def emitTilesetChanged(self, tileset): self.tilesetChanged.emit(tileset) ## # Emits the signal notifying about the terrain probability of a tile changing. ## def emitTileProbabilityChanged(self, tile): self.tileProbabilityChanged.emit(tile) ## # Emits the signal notifying tileset models about changes to tile terrain # information. All the \a tiles need to be from the same tileset. ## def emitTileTerrainChanged(self, tiles): if (not tiles.isEmpty()): self.tileTerrainChanged.emit(tiles) ## # Emits the signal notifying the TileCollisionEditor about the object group # of a tile changing. ## def emitTileObjectGroupChanged(self, tile): self.tileObjectGroupChanged.emit(tile) ## # Emits the signal notifying about the animation of a tile changing. ## def emitTileAnimationChanged(self, tile): self.tileAnimationChanged.emit(tile) ## # Emits the objectGroupChanged signal, should be called when changing the # color or drawing order of an object group. ## def emitObjectGroupChanged(self, objectGroup): self.objectGroupChanged.emit(objectGroup) ## # Emits the imageLayerChanged signal, should be called when changing the # image or the transparent color of an image layer. ## def emitImageLayerChanged(self, imageLayer): self.imageLayerChanged.emit(imageLayer) ## # Emits the editLayerNameRequested signal, to get renamed. ## def emitEditLayerNameRequested(self): self.editLayerNameRequested.emit() ## # Emits the editCurrentObject signal, which makes the Properties window become # visible and take focus. ## def emitEditCurrentObject(self): self.editCurrentObject.emit() ## # Before forwarding the signal, the objects are removed from the list of # selected objects, triggering a selectedObjectsChanged signal when # appropriate. ## def onObjectsRemoved(self, objects): self.deselectObjects(objects) self.objectsRemoved.emit(objects) def onMapObjectModelRowsInserted(self, parent, first, last): objectGroup = self.mMapObjectModel.toObjectGroup(parent) if (not objectGroup): # we're not dealing with insertion of objects return self.objectsInserted.emit(objectGroup, first, last) self.onMapObjectModelRowsInsertedOrRemoved(parent, first, last) def onMapObjectModelRowsInsertedOrRemoved(self, parent, first, last): objectGroup = self.mMapObjectModel.toObjectGroup(parent) if (not objectGroup): return # Inserting or removing objects changes the index of any that come after lastIndex = objectGroup.objectCount() - 1 if (last < lastIndex): self.objectsIndexChanged.emit(objectGroup, last + 1, lastIndex) def onObjectsMoved(self, parent, start, end, destination, row): if (parent != destination): return objectGroup = self.mMapObjectModel.toObjectGroup(parent) # Determine the full range over which object indexes changed first = min(start, row) last = max(end, row - 1) self.objectsIndexChanged.emit(objectGroup, first, last) def onLayerAdded(self, index): self.layerAdded.emit(index) # Select the first layer that gets added to the map if (self.mMap.layerCount() == 1): self.setCurrentLayerIndex(0) def onLayerAboutToBeRemoved(self, index): layer = self.mMap.layerAt(index) if (layer == self.mCurrentObject): self.setCurrentObject(None) # Deselect any objects on this layer when necessary og = layer if type(og) == ObjectGroup: self.deselectObjects(og.objects()) self.layerAboutToBeRemoved.emit(index) def onLayerRemoved(self, index): # Bring the current layer index to safety currentLayerRemoved = self.mCurrentLayerIndex == self.mMap.layerCount() if (currentLayerRemoved): self.mCurrentLayerIndex = self.mCurrentLayerIndex - 1 self.layerRemoved.emit(index) # Emitted after the layerRemoved signal so that the MapScene has a chance # of synchronizing before adapting to the newly selected index if (currentLayerRemoved): self.currentLayerIndexChanged.emit(self.mCurrentLayerIndex) def onTerrainRemoved(self, terrain): if (terrain == self.mCurrentObject): self.setCurrentObject(None) def setFileName(self, fileName): if (self.mFileName == fileName): return oldFileName = self.mFileName self.mFileName = fileName self.fileNameChanged.emit(fileName, oldFileName) def deselectObjects(self, objects): # Unset the current object when it was part of this list of objects if (self.mCurrentObject and self.mCurrentObject.typeId() == Object.MapObjectType): if (objects.contains(self.mCurrentObject)): self.setCurrentObject(None) removedCount = 0 for object in objects: removedCount += self.mSelectedObjects.removeAll(object) if (removedCount > 0): self.selectedObjectsChanged.emit() def disconnect(self): try: super().disconnect() except: pass
class IconEditorGrid(QWidget): """ Class implementing the icon editor grid. @signal canRedoChanged(bool) emitted after the redo status has changed @signal canUndoChanged(bool) emitted after the undo status has changed @signal clipboardImageAvailable(bool) emitted to signal the availability of an image to be pasted @signal colorChanged(QColor) emitted after the drawing color was changed @signal imageChanged(bool) emitted after the image was modified @signal positionChanged(int, int) emitted after the cursor poition was changed @signal previewChanged(QPixmap) emitted to signal a new preview pixmap @signal selectionAvailable(bool) emitted to signal a change of the selection @signal sizeChanged(int, int) emitted after the size has been changed @signal zoomChanged(int) emitted to signal a change of the zoom value """ canRedoChanged = pyqtSignal(bool) canUndoChanged = pyqtSignal(bool) clipboardImageAvailable = pyqtSignal(bool) colorChanged = pyqtSignal(QColor) imageChanged = pyqtSignal(bool) positionChanged = pyqtSignal(int, int) previewChanged = pyqtSignal(QPixmap) selectionAvailable = pyqtSignal(bool) sizeChanged = pyqtSignal(int, int) zoomChanged = pyqtSignal(int) Pencil = 1 Rubber = 2 Line = 3 Rectangle = 4 FilledRectangle = 5 Circle = 6 FilledCircle = 7 Ellipse = 8 FilledEllipse = 9 Fill = 10 ColorPicker = 11 RectangleSelection = 20 CircleSelection = 21 MarkColor = QColor(255, 255, 255, 255) NoMarkColor = QColor(0, 0, 0, 0) ZoomMinimum = 100 ZoomMaximum = 10000 ZoomStep = 100 ZoomDefault = 1200 ZoomPercent = True def __init__(self, parent=None): """ Constructor @param parent reference to the parent widget (QWidget) """ super(IconEditorGrid, self).__init__(parent) self.setAttribute(Qt.WA_StaticContents) self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) self.__curColor = Qt.black self.__zoom = 12 self.__curTool = self.Pencil self.__startPos = QPoint() self.__endPos = QPoint() self.__dirty = False self.__selecting = False self.__selRect = QRect() self.__isPasting = False self.__clipboardSize = QSize() self.__pasteRect = QRect() self.__undoStack = QUndoStack(self) self.__currentUndoCmd = None self.__image = QImage(32, 32, QImage.Format_ARGB32) self.__image.fill(qRgba(0, 0, 0, 0)) self.__markImage = QImage(self.__image) self.__markImage.fill(self.NoMarkColor.rgba()) self.__compositingMode = QPainter.CompositionMode_SourceOver self.__lastPos = (-1, -1) self.__gridEnabled = True self.__selectionAvailable = False self.__initCursors() self.__initUndoTexts() self.setMouseTracking(True) self.__undoStack.canRedoChanged.connect(self.canRedoChanged) self.__undoStack.canUndoChanged.connect(self.canUndoChanged) self.__undoStack.cleanChanged.connect(self.__cleanChanged) self.imageChanged.connect(self.__updatePreviewPixmap) QApplication.clipboard().dataChanged.connect(self.__checkClipboard) self.__checkClipboard() def __initCursors(self): """ Private method to initialize the various cursors. """ self.__normalCursor = QCursor(Qt.ArrowCursor) pix = QPixmap(":colorpicker-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__colorPickerCursor = QCursor(pix, 1, 21) pix = QPixmap(":paintbrush-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__paintCursor = QCursor(pix, 0, 19) pix = QPixmap(":fill-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__fillCursor = QCursor(pix, 3, 20) pix = QPixmap(":aim-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__aimCursor = QCursor(pix, 10, 10) pix = QPixmap(":eraser-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__rubberCursor = QCursor(pix, 1, 16) def __initUndoTexts(self): """ Private method to initialize texts to be associated with undo commands for the various drawing tools. """ self.__undoTexts = { self.Pencil: self.tr("Set Pixel"), self.Rubber: self.tr("Erase Pixel"), self.Line: self.tr("Draw Line"), self.Rectangle: self.tr("Draw Rectangle"), self.FilledRectangle: self.tr("Draw Filled Rectangle"), self.Circle: self.tr("Draw Circle"), self.FilledCircle: self.tr("Draw Filled Circle"), self.Ellipse: self.tr("Draw Ellipse"), self.FilledEllipse: self.tr("Draw Filled Ellipse"), self.Fill: self.tr("Fill Region"), } def isDirty(self): """ Public method to check the dirty status. @return flag indicating a modified status (boolean) """ return self.__dirty def setDirty(self, dirty, setCleanState=False): """ Public slot to set the dirty flag. @param dirty flag indicating the new modification status (boolean) @param setCleanState flag indicating to set the undo stack to clean (boolean) """ self.__dirty = dirty self.imageChanged.emit(dirty) if not dirty and setCleanState: self.__undoStack.setClean() def sizeHint(self): """ Public method to report the size hint. @return size hint (QSize) """ size = self.__zoom * self.__image.size() if self.__zoom >= 3 and self.__gridEnabled: size += QSize(1, 1) return size def setPenColor(self, newColor): """ Public method to set the drawing color. @param newColor reference to the new color (QColor) """ self.__curColor = QColor(newColor) self.colorChanged.emit(QColor(newColor)) def penColor(self): """ Public method to get the current drawing color. @return current drawing color (QColor) """ return QColor(self.__curColor) def setCompositingMode(self, mode): """ Public method to set the compositing mode. @param mode compositing mode to set (QPainter.CompositionMode) """ self.__compositingMode = mode def compositingMode(self): """ Public method to get the compositing mode. @return compositing mode (QPainter.CompositionMode) """ return self.__compositingMode def setTool(self, tool): """ Public method to set the current drawing tool. @param tool drawing tool to be used (IconEditorGrid.Pencil ... IconEditorGrid.CircleSelection) """ self.__curTool = tool self.__lastPos = (-1, -1) if self.__curTool in [self.RectangleSelection, self.CircleSelection]: self.__selecting = True else: self.__selecting = False if self.__curTool in [ self.RectangleSelection, self.CircleSelection, self.Line, self.Rectangle, self.FilledRectangle, self.Circle, self.FilledCircle, self.Ellipse, self.FilledEllipse ]: self.setCursor(self.__aimCursor) elif self.__curTool == self.Fill: self.setCursor(self.__fillCursor) elif self.__curTool == self.ColorPicker: self.setCursor(self.__colorPickerCursor) elif self.__curTool == self.Pencil: self.setCursor(self.__paintCursor) elif self.__curTool == self.Rubber: self.setCursor(self.__rubberCursor) else: self.setCursor(self.__normalCursor) def tool(self): """ Public method to get the current drawing tool. @return current drawing tool (IconEditorGrid.Pencil ... IconEditorGrid.CircleSelection) """ return self.__curTool def setIconImage(self, newImage, undoRedo=False, clearUndo=False): """ Public method to set a new icon image. @param newImage reference to the new image (QImage) @keyparam undoRedo flag indicating an undo or redo operation (boolean) @keyparam clearUndo flag indicating to clear the undo stack (boolean) """ if newImage != self.__image: self.__image = newImage.convertToFormat(QImage.Format_ARGB32) self.update() self.updateGeometry() self.resize(self.sizeHint()) self.__markImage = QImage(self.__image) self.__markImage.fill(self.NoMarkColor.rgba()) if undoRedo: self.setDirty(not self.__undoStack.isClean()) else: self.setDirty(False) if clearUndo: self.__undoStack.clear() self.sizeChanged.emit(*self.iconSize()) def iconImage(self): """ Public method to get a copy of the icon image. @return copy of the icon image (QImage) """ return QImage(self.__image) def iconSize(self): """ Public method to get the size of the icon. @return width and height of the image as a tuple (integer, integer) """ return self.__image.width(), self.__image.height() def setZoomFactor(self, newZoom): """ Public method to set the zoom factor in percent. @param newZoom zoom factor (integer >= 100) """ newZoom = max(100, newZoom) # must not be less than 100 if newZoom != self.__zoom: self.__zoom = newZoom // 100 self.update() self.updateGeometry() self.resize(self.sizeHint()) self.zoomChanged.emit(int(self.__zoom * 100)) def zoomFactor(self): """ Public method to get the current zoom factor in percent. @return zoom factor (integer) """ return self.__zoom * 100 def setGridEnabled(self, enable): """ Public method to enable the display of grid lines. @param enable enabled status of the grid lines (boolean) """ if enable != self.__gridEnabled: self.__gridEnabled = enable self.update() def isGridEnabled(self): """ Public method to get the grid lines status. @return enabled status of the grid lines (boolean) """ return self.__gridEnabled def paintEvent(self, evt): """ Protected method called to repaint some of the widget. @param evt reference to the paint event object (QPaintEvent) """ painter = QPainter(self) if self.__zoom >= 3 and self.__gridEnabled: painter.setPen(self.palette().windowText().color()) i = 0 while i <= self.__image.width(): painter.drawLine(self.__zoom * i, 0, self.__zoom * i, self.__zoom * self.__image.height()) i += 1 j = 0 while j <= self.__image.height(): painter.drawLine(0, self.__zoom * j, self.__zoom * self.__image.width(), self.__zoom * j) j += 1 col = QColor("#aaa") painter.setPen(Qt.DashLine) for i in range(0, self.__image.width()): for j in range(0, self.__image.height()): rect = self.__pixelRect(i, j) if evt.region().intersects(rect): color = QColor.fromRgba(self.__image.pixel(i, j)) painter.fillRect(rect, QBrush(Qt.white)) painter.fillRect(QRect(rect.topLeft(), rect.center()), col) painter.fillRect(QRect(rect.center(), rect.bottomRight()), col) painter.fillRect(rect, QBrush(color)) if self.__isMarked(i, j): painter.drawRect(rect.adjusted(0, 0, -1, -1)) painter.end() def __pixelRect(self, i, j): """ Private method to determine the rectangle for a given pixel coordinate. @param i x-coordinate of the pixel in the image (integer) @param j y-coordinate of the pixel in the image (integer) @return rectangle for the given pixel coordinates (QRect) """ if self.__zoom >= 3 and self.__gridEnabled: return QRect(self.__zoom * i + 1, self.__zoom * j + 1, self.__zoom - 1, self.__zoom - 1) else: return QRect(self.__zoom * i, self.__zoom * j, self.__zoom, self.__zoom) def mousePressEvent(self, evt): """ Protected method to handle mouse button press events. @param evt reference to the mouse event object (QMouseEvent) """ if evt.button() == Qt.LeftButton: if self.__isPasting: self.__isPasting = False self.editPaste(True) self.__markImage.fill(self.NoMarkColor.rgba()) self.update(self.__pasteRect) self.__pasteRect = QRect() return if self.__curTool == self.Pencil: cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) self.__setImagePixel(evt.pos(), True) self.setDirty(True) self.__undoStack.push(cmd) self.__currentUndoCmd = cmd elif self.__curTool == self.Rubber: cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) self.__setImagePixel(evt.pos(), False) self.setDirty(True) self.__undoStack.push(cmd) self.__currentUndoCmd = cmd elif self.__curTool == self.Fill: i, j = self.__imageCoordinates(evt.pos()) col = QColor() col.setRgba(self.__image.pixel(i, j)) cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) self.__drawFlood(i, j, col) self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) elif self.__curTool == self.ColorPicker: i, j = self.__imageCoordinates(evt.pos()) col = QColor() col.setRgba(self.__image.pixel(i, j)) self.setPenColor(col) else: self.__unMark() self.__startPos = evt.pos() self.__endPos = evt.pos() def mouseMoveEvent(self, evt): """ Protected method to handle mouse move events. @param evt reference to the mouse event object (QMouseEvent) """ self.positionChanged.emit(*self.__imageCoordinates(evt.pos())) if self.__isPasting and not (evt.buttons() & Qt.LeftButton): self.__drawPasteRect(evt.pos()) return if evt.buttons() & Qt.LeftButton: if self.__curTool == self.Pencil: self.__setImagePixel(evt.pos(), True) self.setDirty(True) elif self.__curTool == self.Rubber: self.__setImagePixel(evt.pos(), False) self.setDirty(True) elif self.__curTool in [self.Fill, self.ColorPicker]: pass # do nothing else: self.__drawTool(evt.pos(), True) def mouseReleaseEvent(self, evt): """ Protected method to handle mouse button release events. @param evt reference to the mouse event object (QMouseEvent) """ if evt.button() == Qt.LeftButton: if self.__curTool in [self.Pencil, self.Rubber]: if self.__currentUndoCmd: self.__currentUndoCmd.setAfterImage(self.__image) self.__currentUndoCmd = None if self.__curTool not in [ self.Pencil, self.Rubber, self.Fill, self.ColorPicker, self.RectangleSelection, self.CircleSelection ]: cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) if self.__drawTool(evt.pos(), False): self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) self.setDirty(True) def __setImagePixel(self, pos, opaque): """ Private slot to set or erase a pixel. @param pos position of the pixel in the widget (QPoint) @param opaque flag indicating a set operation (boolean) """ i, j = self.__imageCoordinates(pos) if self.__image.rect().contains(i, j) and (i, j) != self.__lastPos: if opaque: painter = QPainter(self.__image) painter.setPen(self.penColor()) painter.setCompositionMode(self.__compositingMode) painter.drawPoint(i, j) else: self.__image.setPixel(i, j, qRgba(0, 0, 0, 0)) self.__lastPos = (i, j) self.update(self.__pixelRect(i, j)) def __imageCoordinates(self, pos): """ Private method to convert from widget to image coordinates. @param pos widget coordinate (QPoint) @return tuple with the image coordinates (tuple of two integers) """ i = pos.x() // self.__zoom j = pos.y() // self.__zoom return i, j def __drawPasteRect(self, pos): """ Private slot to draw a rectangle for signaling a paste operation. @param pos widget position of the paste rectangle (QPoint) """ self.__markImage.fill(self.NoMarkColor.rgba()) if self.__pasteRect.isValid(): self.__updateImageRect( self.__pasteRect.topLeft(), self.__pasteRect.bottomRight() + QPoint(1, 1)) x, y = self.__imageCoordinates(pos) isize = self.__image.size() if x + self.__clipboardSize.width() <= isize.width(): sx = self.__clipboardSize.width() else: sx = isize.width() - x if y + self.__clipboardSize.height() <= isize.height(): sy = self.__clipboardSize.height() else: sy = isize.height() - y self.__pasteRect = QRect(QPoint(x, y), QSize(sx - 1, sy - 1)) painter = QPainter(self.__markImage) painter.setPen(self.MarkColor) painter.drawRect(self.__pasteRect) painter.end() self.__updateImageRect(self.__pasteRect.topLeft(), self.__pasteRect.bottomRight() + QPoint(1, 1)) def __drawTool(self, pos, mark): """ Private method to perform a draw operation depending of the current tool. @param pos widget coordinate to perform the draw operation at (QPoint) @param mark flag indicating a mark operation (boolean) @return flag indicating a successful draw (boolean) """ self.__unMark() if mark: self.__endPos = QPoint(pos) drawColor = self.MarkColor img = self.__markImage else: drawColor = self.penColor() img = self.__image start = QPoint(*self.__imageCoordinates(self.__startPos)) end = QPoint(*self.__imageCoordinates(pos)) painter = QPainter(img) painter.setPen(drawColor) painter.setCompositionMode(self.__compositingMode) if self.__curTool == self.Line: painter.drawLine(start, end) elif self.__curTool in [ self.Rectangle, self.FilledRectangle, self.RectangleSelection ]: left = min(start.x(), end.x()) top = min(start.y(), end.y()) right = max(start.x(), end.x()) bottom = max(start.y(), end.y()) if self.__curTool == self.RectangleSelection: painter.setBrush(QBrush(drawColor)) if self.__curTool == self.FilledRectangle: for y in range(top, bottom + 1): painter.drawLine(left, y, right, y) else: painter.drawRect(left, top, right - left, bottom - top) if self.__selecting: self.__selRect = QRect(left, top, right - left + 1, bottom - top + 1) self.__selectionAvailable = True self.selectionAvailable.emit(True) elif self.__curTool in [ self.Circle, self.FilledCircle, self.CircleSelection ]: r = max(abs(start.x() - end.x()), abs(start.y() - end.y())) if self.__curTool in [self.FilledCircle, self.CircleSelection]: painter.setBrush(QBrush(drawColor)) painter.drawEllipse(start, r, r) if self.__selecting: self.__selRect = QRect(start.x() - r, start.y() - r, 2 * r + 1, 2 * r + 1) self.__selectionAvailable = True self.selectionAvailable.emit(True) elif self.__curTool in [self.Ellipse, self.FilledEllipse]: r1 = abs(start.x() - end.x()) r2 = abs(start.y() - end.y()) if r1 == 0 or r2 == 0: return False if self.__curTool == self.FilledEllipse: painter.setBrush(QBrush(drawColor)) painter.drawEllipse(start, r1, r2) painter.end() if self.__curTool in [ self.Circle, self.FilledCircle, self.Ellipse, self.FilledEllipse ]: self.update() else: self.__updateRect(self.__startPos, pos) return True def __drawFlood(self, i, j, oldColor, doUpdate=True): """ Private method to perform a flood fill operation. @param i x-value in image coordinates (integer) @param j y-value in image coordinates (integer) @param oldColor reference to the color at position i, j (QColor) @param doUpdate flag indicating an update is requested (boolean) (used for speed optimizations) """ if not self.__image.rect().contains(i, j) or \ self.__image.pixel(i, j) != oldColor.rgba() or \ self.__image.pixel(i, j) == self.penColor().rgba(): return self.__image.setPixel(i, j, self.penColor().rgba()) self.__drawFlood(i, j - 1, oldColor, False) self.__drawFlood(i, j + 1, oldColor, False) self.__drawFlood(i - 1, j, oldColor, False) self.__drawFlood(i + 1, j, oldColor, False) if doUpdate: self.update() def __updateRect(self, pos1, pos2): """ Private slot to update parts of the widget. @param pos1 top, left position for the update in widget coordinates (QPoint) @param pos2 bottom, right position for the update in widget coordinates (QPoint) """ self.__updateImageRect(QPoint(*self.__imageCoordinates(pos1)), QPoint(*self.__imageCoordinates(pos2))) def __updateImageRect(self, ipos1, ipos2): """ Private slot to update parts of the widget. @param ipos1 top, left position for the update in image coordinates (QPoint) @param ipos2 bottom, right position for the update in image coordinates (QPoint) """ r1 = self.__pixelRect(ipos1.x(), ipos1.y()) r2 = self.__pixelRect(ipos2.x(), ipos2.y()) left = min(r1.x(), r2.x()) top = min(r1.y(), r2.y()) right = max(r1.x() + r1.width(), r2.x() + r2.width()) bottom = max(r1.y() + r1.height(), r2.y() + r2.height()) self.update(left, top, right - left + 1, bottom - top + 1) def __unMark(self): """ Private slot to remove the mark indicator. """ self.__markImage.fill(self.NoMarkColor.rgba()) if self.__curTool in [ self.Circle, self.FilledCircle, self.Ellipse, self.FilledEllipse, self.CircleSelection ]: self.update() else: self.__updateRect(self.__startPos, self.__endPos) if self.__selecting: self.__selRect = QRect() self.__selectionAvailable = False self.selectionAvailable.emit(False) def __isMarked(self, i, j): """ Private method to check, if a pixel is marked. @param i x-value in image coordinates (integer) @param j y-value in image coordinates (integer) @return flag indicating a marked pixel (boolean) """ return self.__markImage.pixel(i, j) == self.MarkColor.rgba() def __updatePreviewPixmap(self): """ Private slot to generate and signal an updated preview pixmap. """ p = QPixmap.fromImage(self.__image) self.previewChanged.emit(p) def previewPixmap(self): """ Public method to generate a preview pixmap. @return preview pixmap (QPixmap) """ p = QPixmap.fromImage(self.__image) return p def __checkClipboard(self): """ Private slot to check, if the clipboard contains a valid image, and signal the result. """ ok = self.__clipboardImage()[1] self.__clipboardImageAvailable = ok self.clipboardImageAvailable.emit(ok) def canPaste(self): """ Public slot to check the availability of the paste operation. @return flag indicating availability of paste (boolean) """ return self.__clipboardImageAvailable def __clipboardImage(self): """ Private method to get an image from the clipboard. @return tuple with the image (QImage) and a flag indicating a valid image (boolean) """ img = QApplication.clipboard().image() ok = not img.isNull() if ok: img = img.convertToFormat(QImage.Format_ARGB32) return img, ok def __getSelectionImage(self, cut): """ Private method to get an image from the selection. @param cut flag indicating to cut the selection (boolean) @return image of the selection (QImage) """ if cut: cmd = IconEditCommand(self, self.tr("Cut Selection"), self.__image) img = QImage(self.__selRect.size(), QImage.Format_ARGB32) img.fill(qRgba(0, 0, 0, 0)) for i in range(0, self.__selRect.width()): for j in range(0, self.__selRect.height()): if self.__image.rect().contains(self.__selRect.x() + i, self.__selRect.y() + j): if self.__isMarked(self.__selRect.x() + i, self.__selRect.y() + j): img.setPixel( i, j, self.__image.pixel(self.__selRect.x() + i, self.__selRect.y() + j)) if cut: self.__image.setPixel(self.__selRect.x() + i, self.__selRect.y() + j, qRgba(0, 0, 0, 0)) if cut: self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) self.__unMark() if cut: self.update(self.__selRect) return img def editCopy(self): """ Public slot to copy the selection. """ if self.__selRect.isValid(): img = self.__getSelectionImage(False) QApplication.clipboard().setImage(img) def editCut(self): """ Public slot to cut the selection. """ if self.__selRect.isValid(): img = self.__getSelectionImage(True) QApplication.clipboard().setImage(img) @pyqtSlot() def editPaste(self, pasting=False): """ Public slot to paste an image from the clipboard. @param pasting flag indicating part two of the paste operation (boolean) """ img, ok = self.__clipboardImage() if ok: if img.width() > self.__image.width() or \ img.height() > self.__image.height(): res = E5MessageBox.yesNo( self, self.tr("Paste"), self.tr("""<p>The clipboard image is larger than the""" """ current image.<br/>Paste as new image?</p>""")) if res: self.editPasteAsNew() return elif not pasting: self.__isPasting = True self.__clipboardSize = img.size() else: cmd = IconEditCommand(self, self.tr("Paste Clipboard"), self.__image) self.__markImage.fill(self.NoMarkColor.rgba()) painter = QPainter(self.__image) painter.setPen(self.penColor()) painter.setCompositionMode(self.__compositingMode) painter.drawImage(self.__pasteRect.x(), self.__pasteRect.y(), img, 0, 0, self.__pasteRect.width() + 1, self.__pasteRect.height() + 1) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) self.__updateImageRect( self.__pasteRect.topLeft(), self.__pasteRect.bottomRight() + QPoint(1, 1)) else: E5MessageBox.warning( self, self.tr("Pasting Image"), self.tr("""Invalid image data in clipboard.""")) def editPasteAsNew(self): """ Public slot to paste the clipboard as a new image. """ img, ok = self.__clipboardImage() if ok: cmd = IconEditCommand(self, self.tr("Paste Clipboard as New Image"), self.__image) self.setIconImage(img) self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editSelectAll(self): """ Public slot to select the complete image. """ self.__unMark() self.__startPos = QPoint(0, 0) self.__endPos = QPoint(self.rect().bottomRight()) self.__markImage.fill(self.MarkColor.rgba()) self.__selRect = self.__image.rect() self.__selectionAvailable = True self.selectionAvailable.emit(True) self.update() def editClear(self): """ Public slot to clear the image. """ self.__unMark() cmd = IconEditCommand(self, self.tr("Clear Image"), self.__image) self.__image.fill(qRgba(0, 0, 0, 0)) self.update() self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editResize(self): """ Public slot to resize the image. """ from .IconSizeDialog import IconSizeDialog dlg = IconSizeDialog(self.__image.width(), self.__image.height()) res = dlg.exec_() if res == QDialog.Accepted: newWidth, newHeight = dlg.getData() if newWidth != self.__image.width() or \ newHeight != self.__image.height(): cmd = IconEditCommand(self, self.tr("Resize Image"), self.__image) img = self.__image.scaled(newWidth, newHeight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) self.setIconImage(img) self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editNew(self): """ Public slot to generate a new, empty image. """ from .IconSizeDialog import IconSizeDialog dlg = IconSizeDialog(self.__image.width(), self.__image.height()) res = dlg.exec_() if res == QDialog.Accepted: width, height = dlg.getData() img = QImage(width, height, QImage.Format_ARGB32) img.fill(qRgba(0, 0, 0, 0)) self.setIconImage(img) def grayScale(self): """ Public slot to convert the image to gray preserving transparency. """ cmd = IconEditCommand(self, self.tr("Convert to Grayscale"), self.__image) for x in range(self.__image.width()): for y in range(self.__image.height()): col = self.__image.pixel(x, y) if col != qRgba(0, 0, 0, 0): gray = qGray(col) self.__image.setPixel(x, y, qRgba(gray, gray, gray, qAlpha(col))) self.update() self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editUndo(self): """ Public slot to perform an undo operation. """ if self.__undoStack.canUndo(): self.__undoStack.undo() def editRedo(self): """ Public slot to perform a redo operation. """ if self.__undoStack.canRedo(): self.__undoStack.redo() def canUndo(self): """ Public method to return the undo status. @return flag indicating the availability of undo (boolean) """ return self.__undoStack.canUndo() def canRedo(self): """ Public method to return the redo status. @return flag indicating the availability of redo (boolean) """ return self.__undoStack.canRedo() def __cleanChanged(self, clean): """ Private slot to handle the undo stack clean state change. @param clean flag indicating the clean state (boolean) """ self.setDirty(not clean) def shutdown(self): """ Public slot to perform some shutdown actions. """ self.__undoStack.canRedoChanged.disconnect(self.canRedoChanged) self.__undoStack.canUndoChanged.disconnect(self.canUndoChanged) self.__undoStack.cleanChanged.disconnect(self.__cleanChanged) def isSelectionAvailable(self): """ Public method to check the availability of a selection. @return flag indicating the availability of a selection (boolean) """ return self.__selectionAvailable
class PFSNet(QWidget): changed = pyqtSignal() def __init__(self, id: str, window, tempName=None): super(QWidget, self).__init__() self._filename = None self._filepath = None self._tempName = tempName self._id = id layout = QHBoxLayout() self._tab = QTabWidget() self._tab.currentChanged.connect(self.changeTab) self._tab.setTabsClosable(True) self._tab.tabCloseRequested.connect(self.closeTab) layout.addWidget(self._tab) self.setLayout(layout) self._prop = QTableWidget(20, 2) self._prop.itemChanged.connect(self.propertiesItemChanged) self._prop.verticalHeader().hide() self._prop.setColumnWidth(1, 180) self._prop.setMaximumWidth(300) lv = QVBoxLayout() lv.addWidget(self._prop) self._tree = QTreeWidget() self._tree.itemClicked.connect(self.treeItemClicked) self._tree.setMaximumWidth(300) lv.addWidget(self._tree) layout.addLayout(lv) self._pages = [] self._idPage = 0 self._sm = window._sm self._window = window self._distributorId = 0 self._activityId = 0 self._relationId = 0 self._otherId = 0 self._pageId = 0 self._page = None self._elements = {} self.undoStack = QUndoStack(self) self.undoAction = self.undoStack.createUndoAction(self, "Desfazer") self.undoAction.setShortcuts(QKeySequence.Undo) self.undoAction.setIcon( QIcon.fromTheme("edit-undo", QIcon("icons/edit-undo.svg"))) self.redoAction = self.undoStack.createRedoAction(self, "Refazer") self.redoAction.setShortcuts(QKeySequence.Redo) self.redoAction.setIcon( QIcon.fromTheme("edit-redo", QIcon("icons/edit-redo.svg"))) self._pasteList = [] def tree(self): tree = QTreeWidgetItem(self._tree, ["Net " + self._id], 0) child = self._page.tree(tree) self._tree.expandAll() return tree def prepareTree(self): self._tree.clear() self.tree() def showPage(self, widget): if widget in self._pages: self._tab.setCurrentWidget(widget) else: self._tab.addTab(widget, widget.name()) self._pages.append(widget) self._tab.setCurrentWidget(widget) def removeTabWidget(self, widget): for i in range(self._tab.count()): if self._tab.widget(i) == widget: self._tab.removeTab(i) self._pages.remove(widget) def propertiesItemChanged(self, item: PFSTableValueText): if item.comparePrevious(): item.edited.emit(item) def getAllPages(self): ans = [] ans.append(self._page) aux = self._page.getAllSubPages() if len(aux) > 0: ans = ans + aux return ans def generateXml(self, xml: QXmlStreamWriter): xml.writeStartDocument() xml.writeStartElement("PetriNetDoc") xml.writeStartElement("net") xml.writeAttribute("id", self._id) pages = self.getAllPages() for p in pages: p.generateXml(xml) xml.writeEndElement() xml.writeEndElement() xml.writeEndDocument() def treeItemClicked(self, item, col): if isinstance(item, PFSTreeItem): item.clicked.emit() def createFromXml(doc: QDomDocument, window): el = doc.documentElement() nodes = el.childNodes() nets = [] for i in range(nodes.count()): node = nodes.at(i) if node.nodeName() != "net": continue if not (node.hasAttributes() and node.attributes().contains("id")): continue id = node.attributes().namedItem("id").nodeValue() net = PFSNet(id, window) nodesPage = node.childNodes() pages = [] for j in range(nodesPage.count()): nodePage = nodesPage.at(j) if nodePage.nodeName() != "page": continue page = PFSPage.createFromXml(nodePage) if page is not None: pages.append(page) if len(pages) == 0: continue aux = {} for page in pages: p = PFSPage.createFromContent(page, window._sm, net) if p is not None: i = page._ref if i is None or not i: i = "main" aux[i] = p if "main" not in aux.keys(): continue for indice, page in aux.items(): ids = page.getMaxIds() if net._activityId < ids[0] + 1: net._activityId = ids[0] + 1 if net._distributorId < ids[1] + 1: net._distributorId = ids[1] + 1 if net._relationId < ids[2] + 1: net._relationId = ids[2] + 1 if net._otherId < ids[3] + 1: net._otherId = ids[3] + 1 if net._pageId < int(page._id[1:]) + 1: net._pageId = int(page._id[1:]) + 1 if indice == "main": net._tab.blockSignals(True) net._tab.addTab(page, page.name()) net._tab.blockSignals(False) for indice2, page2 in aux.items(): elem = page2.getElementById(indice) if elem is not None: page._subRef = elem elem.setSubPage(page) page.setName("Ref_" + elem._id) break net._page = aux["main"] net._pages.append(aux["main"]) net.tree() nets.append(net) return nets def getTabName(self) -> str: if self._filename is None and self._tempName is None: ans = "New model" elif self._filename is None: ans = self._tempName else: ans = self._filename if self.undoStack.isClean(): return ans return ans + "*" def newNet(id, window, tempName="newmodel.xml"): ans = PFSNet(id, window, tempName) page = PFSPage.newPage(ans.requestId(PFSPage), window._sm, ans) ans._page = page ans._pages.append(page) ans._tab.addTab(page, page.name()) return ans def openPage(self, element): if isinstance(element, PFSPage): page = element elif isinstance(element, PFSActivity): page = element.subPage() else: return if page not in self._pages: self._tab.addTab(page, page.name()) self._pages.append(page) self._tab.setCurrentWidget(page) def createPage(self, element=None): page = PFSPage.newPage(self.requestId(PFSPage), self._sm, self, 600, 120) if element is not None and element.setSubPage(page): page.setName("Ref_" + element._id) page._subRef = element openac = PFSOpenActivity(self.requestId(PFSOpenActivity), 20, 10, 100) self.addItemNoUndo(openac, page) closeac = PFSCloseActivity(self.requestId(PFSCloseActivity), page._scene.sceneRect().width() - 20, 10, 100) self.addItemNoUndo(closeac, page) self._idPage = self._idPage + 1 self._sm.fixTransitions(page._scene) return page return None def deleteElements(self): if len(self._pages) == 0: return scene = self._tab.currentWidget()._scene itemsSeleted = scene.selectedItems() if len(itemsSeleted) == 0: if self._tab.currentWidget() == self._page: return x = PFSUndoDeletePage(self._tab.currentWidget()) self.undoStack.push(x) return itemsDeleted = [] for item in itemsSeleted: if not item.canDelete(): continue if isinstance(item, PFSNode): item.deleted.emit() itemsDeleted.append(item) if len(itemsDeleted) > 0: x = PFSUndoDelete(itemsDeleted) self.undoStack.push(x) def pasteElements(self, elements): self._pasteList = elements def pasteItems(self, pos): ans = [] aux = {} for elem in self._pasteList: if isinstance(elem, PFSRelationContent) or isinstance( elem, PFSSecondaryFlowContent): continue oldId = elem._id id = self.requestId(elem) if isinstance(elem, PFSActivityContent): e = PFSActivity.paste(elem, id, pos.x(), pos.y()) ans.append(e) aux[oldId] = e elif isinstance(elem, PFSDistributorContent): e = PFSDistributor.paste(elem, id, pos.x(), pos.y()) ans.append(e) aux[oldId] = e for elem in self._pasteList: if isinstance(elem, PFSRelationContent): oldId = elem._id id = self.requestId(elem) e = PFSRelation.paste(elem, id, pos.x(), pos.y(), aux) ans.append(e) elif isinstance(elem, PFSSecondaryFlowContent): oldId = elem._id id = self.requestId(elem) e = PFSSecondaryFlow.paste(elem, id, pos.x(), pos.y(), aux) ans.append(e) x = PFSUndoAdd(ans, self._tab.currentWidget()._scene) self.undoStack.push(x) def export(self, filename): if len(self._pages) > 1: scene = self._tab.currentWidget()._scene elif len(self._pages) == 1: scene = self._pages[0]._scene else: return if filename.endswith(".png"): PFSImage.gravaPng(scene, filename) else: PFSImage.gravaSvg(scene, filename) def addItem(self, element, page: PFSPage): if isinstance(element, PFSRelation): if isinstance(element._source, PFSActive) and isinstance( element._target, PFSActive): return False if isinstance(element._source, PFSPassive) and isinstance( element._target, PFSPassive): return False x = PFSUndoAdd([element], page._scene) self.undoStack.push(x) return True def addItemNoUndo(self, element, page: PFSPage): if isinstance(element, PFSRelation): if isinstance(element._source, PFSActive) and isinstance( element._target, PFSActive): return False if isinstance(element._source, PFSPassive) and isinstance( element._target, PFSPassive): return False page._scene.addItem(element) page._scene.update() return True def requestId(self, element): if element == PFSActivity or isinstance(element, PFSActivityContent): ans = "A" + str(self._activityId) self._activityId = self._activityId + 1 elif element == PFSDistributor or isinstance(element, PFSDistributorContent): ans = "D" + str(self._distributorId) self._distributorId = self._distributorId + 1 elif element == PFSRelation: ans = "R" + str(self._relationId) self._relationId = self._relationId + 1 elif element == PFSPage: ans = "P" + str(self._pageId) self._pageId = self._pageId + 1 else: ans = "O" + str(self._otherId) self._otherId = self._otherId + 1 return ans def changeTab(self, index: int): self._prop.clear() self._tree.clear() self.tree() if index < 0: return self._tab.widget(index)._scene.clearSelection() self._window._main.tabChanged.emit() def fillProperties(self, props): if len(props) > 0: self._prop.setRowCount(0) self._prop.setRowCount(len(props)) i = 0 for line in props: if isinstance(line[0], QTableWidgetItem): self._prop.setItem(i, 0, line[0]) else: self._prop.setCellWidget(i, 0, line[0]) if isinstance(line[1], QTableWidgetItem): self._prop.setItem(i, 1, line[1]) else: self._prop.setCellWidget(i, 1, line[1]) if isinstance(line[0], PFSTableLabelTags): self._prop.setRowHeight(i, 100) i = i + 1 def closeTab(self, ind): w = self._tab.widget(ind) self._pages.remove(w) self._tab.removeTab(ind)
class MapDocument(QObject): fileNameChanged = pyqtSignal(str, str) modifiedChanged = pyqtSignal() saved = pyqtSignal() ## # Emitted when the selected tile region changes. Sends the currently # selected region and the previously selected region. ## selectedAreaChanged = pyqtSignal(QRegion, QRegion) ## # Emitted when the list of selected objects changes. ## selectedObjectsChanged = pyqtSignal() ## # Emitted when the list of selected tiles from the dock changes. ## selectedTilesChanged = pyqtSignal() currentObjectChanged = pyqtSignal(list) ## # Emitted when the map size or its tile size changes. ## mapChanged = pyqtSignal() layerAdded = pyqtSignal(int) layerAboutToBeRemoved = pyqtSignal(int) layerRenamed = pyqtSignal(int) layerRemoved = pyqtSignal(int) layerChanged = pyqtSignal(int) ## # Emitted after a new layer was added and the name should be edited. # Applies to the current layer. ## editLayerNameRequested = pyqtSignal() editCurrentObject = pyqtSignal() ## # Emitted when the current layer index changes. ## currentLayerIndexChanged = pyqtSignal(int) ## # Emitted when a certain region of the map changes. The region is given in # tile coordinates. ## regionChanged = pyqtSignal(QRegion, Layer) ## # Emitted when a certain region of the map was edited by user input. # The region is given in tile coordinates. # If multiple layers have been edited, multiple signals will be emitted. ## regionEdited = pyqtSignal(QRegion, Layer) tileLayerDrawMarginsChanged = pyqtSignal(TileLayer) tileTerrainChanged = pyqtSignal(QList) tileProbabilityChanged = pyqtSignal(Tile) tileObjectGroupChanged = pyqtSignal(Tile) tileAnimationChanged = pyqtSignal(Tile) objectGroupChanged = pyqtSignal(ObjectGroup) imageLayerChanged = pyqtSignal(ImageLayer) tilesetAboutToBeAdded = pyqtSignal(int) tilesetAdded = pyqtSignal(int, Tileset) tilesetAboutToBeRemoved = pyqtSignal(int) tilesetRemoved = pyqtSignal(Tileset) tilesetMoved = pyqtSignal(int, int) tilesetFileNameChanged = pyqtSignal(Tileset) tilesetNameChanged = pyqtSignal(Tileset) tilesetTileOffsetChanged = pyqtSignal(Tileset) tilesetChanged = pyqtSignal(Tileset) objectsAdded = pyqtSignal(QList) objectsInserted = pyqtSignal(ObjectGroup, int, int) objectsRemoved = pyqtSignal(QList) objectsChanged = pyqtSignal(QList) objectsIndexChanged = pyqtSignal(ObjectGroup, int, int) propertyAdded = pyqtSignal(Object, str) propertyRemoved = pyqtSignal(Object, str) propertyChanged = pyqtSignal(Object, str) propertiesChanged = pyqtSignal(Object) ## # Constructs a map document around the given map. The map document takes # ownership of the map. ## def __init__(self, map, fileName = QString()): super().__init__() ## # The filename of a plugin is unique. So it can be used to determine # the right plugin to be used for saving or reloading the map. # The nameFilter of a plugin can not be used, since it's translatable. # The filename of a plugin must not change while maps are open using this # plugin. ## self.mReaderFormat = None self.mWriterFormat = None self.mExportFormat = None self.mSelectedArea = QRegion() self.mSelectedObjects = QList() self.mSelectedTiles = QList() self.mCurrentLayerIndex = 0 self.mLastSaved = QDateTime() self.mLastExportFileName = '' self.mFileName = fileName self.mMap = map self.mLayerModel = LayerModel(self) self.mCurrentObject = map ## Current properties object. ## self.mRenderer = None self.mMapObjectModel = MapObjectModel(self) self.mTerrainModel = TerrainModel(self, self) self.mUndoStack = QUndoStack(self) self.createRenderer() if (map.layerCount() == 0): _x = -1 else: _x = 0 self.mCurrentLayerIndex = _x self.mLayerModel.setMapDocument(self) # Forward signals emitted from the layer model self.mLayerModel.layerAdded.connect(self.onLayerAdded) self.mLayerModel.layerAboutToBeRemoved.connect(self.onLayerAboutToBeRemoved) self.mLayerModel.layerRemoved.connect(self.onLayerRemoved) self.mLayerModel.layerChanged.connect(self.layerChanged) # Forward signals emitted from the map object model self.mMapObjectModel.setMapDocument(self) self.mMapObjectModel.objectsAdded.connect(self.objectsAdded) self.mMapObjectModel.objectsChanged.connect(self.objectsChanged) self.mMapObjectModel.objectsRemoved.connect(self.onObjectsRemoved) self.mMapObjectModel.rowsInserted.connect(self.onMapObjectModelRowsInserted) self.mMapObjectModel.rowsRemoved.connect(self.onMapObjectModelRowsInsertedOrRemoved) self.mMapObjectModel.rowsMoved.connect(self.onObjectsMoved) self.mTerrainModel.terrainRemoved.connect(self.onTerrainRemoved) self.mUndoStack.cleanChanged.connect(self.modifiedChanged) # Register tileset references tilesetManager = TilesetManager.instance() tilesetManager.addReferences(self.mMap.tilesets()) ## # Destructor. ## def __del__(self): # Unregister tileset references tilesetManager = TilesetManager.instance() tilesetManager.removeReferences(self.mMap.tilesets()) del self.mRenderer del self.mMap ## # Saves the map to its current file name. Returns whether or not the file # was saved successfully. If not, <i>error</i> will be set to the error # message if it is not 0. ## def save(self, *args): l = len(args) if l==0: args = ('') if l==1: arg = args[0] file = QFileInfo(arg) if not file.isFile(): fileName = self.fileName() error = args[0] else: fileName = arg error = '' return self.save(fileName, error) if l==2: ## # Saves the map to the file at \a fileName. Returns whether or not the # file was saved successfully. If not, <i>error</i> will be set to the # error message if it is not 0. # # If the save was successful, the file name of this document will be set # to \a fileName. # # The map format will be the same as this map was opened with. ## fileName, error = args mapFormat = self.mWriterFormat tmxMapFormat = TmxMapFormat() if (not mapFormat): mapFormat = tmxMapFormat if (not mapFormat.write(self.map(), fileName)): if (error): error = mapFormat.errorString() return False self.undoStack().setClean() self.setFileName(fileName) self.mLastSaved = QFileInfo(fileName).lastModified() self.saved.emit() return True ## # Loads a map and returns a MapDocument instance on success. Returns 0 # on error and sets the \a error message. ## def load(fileName, mapFormat = None): error = '' tmxMapFormat = TmxMapFormat() if (not mapFormat and not tmxMapFormat.supportsFile(fileName)): # Try to find a plugin that implements support for this format formats = PluginManager.objects() for format in formats: if (format.supportsFile(fileName)): mapFormat = format break map = None errorString = '' if mapFormat: map = mapFormat.read(fileName) errorString = mapFormat.errorString() else: map = tmxMapFormat.read(fileName) errorString = tmxMapFormat.errorString() if (not map): error = errorString return None, error mapDocument = MapDocument(map, fileName) if mapFormat: mapDocument.setReaderFormat(mapFormat) if mapFormat.hasCapabilities(MapFormat.Write): mapDocument.setWriterFormat(mapFormat) return mapDocument, error def fileName(self): return self.mFileName def lastExportFileName(self): return self.mLastExportFileName def setLastExportFileName(self, fileName): self.mLastExportFileName = fileName def readerFormat(self): return self.mReaderFormat def setReaderFormat(self, format): self.mReaderFormat = format def writerFormat(self): return self.mWriterFormat def setWriterFormat(self, format): self.mWriterFormat = format def exportFormat(self): return self.mExportFormat def setExportFormat(self, format): self.mExportFormat = format ## # Returns the name with which to display this map. It is the file name without # its path, or 'untitled.tmx' when the map has no file name. ## def displayName(self): displayName = QFileInfo(self.mFileName).fileName() if len(displayName)==0: displayName = self.tr("untitled.tmx") return displayName ## # Returns whether the map has unsaved changes. ## def isModified(self): return not self.mUndoStack.isClean() def lastSaved(self): return self.mLastSaved ## # Returns the map instance. Be aware that directly modifying the map will # not allow the GUI to update itself appropriately. ## def map(self): return self.mMap ## # Sets the current layer to the given index. ## def setCurrentLayerIndex(self, index): changed = self.mCurrentLayerIndex != index self.mCurrentLayerIndex = index ## This function always sends the following signal, even if the index # didn't actually change. This is because the selected index in the layer # table view might be out of date anyway, and would otherwise not be # properly updated. # # This problem happens due to the selection model not sending signals # about changes to its current index when it is due to insertion/removal # of other items. The selected item doesn't change in that case, but our # layer index does. ## self.currentLayerIndexChanged.emit(self.mCurrentLayerIndex) if (changed and self.mCurrentLayerIndex != -1): self.setCurrentObject(self.currentLayer()) ## # Returns the index of the currently selected layer. Returns -1 if no # layer is currently selected. ## def currentLayerIndex(self): return self.mCurrentLayerIndex ## # Returns the currently selected layer, or 0 if no layer is currently # selected. ## def currentLayer(self): if (self.mCurrentLayerIndex == -1): return None return self.mMap.layerAt(self.mCurrentLayerIndex) ## # Resize this map to the given \a size, while at the same time shifting # the contents by \a offset. ## def resizeMap(self, size, offset): movedSelection = self.mSelectedArea.translated(offset) newArea = QRect(-offset, size) visibleArea = self.mRenderer.boundingRect(newArea) origin = self.mRenderer.tileToPixelCoords_(QPointF()) newOrigin = self.mRenderer.tileToPixelCoords_(-offset) pixelOffset = origin - newOrigin # Resize the map and each layer self.mUndoStack.beginMacro(self.tr("Resize Map")) for i in range(self.mMap.layerCount()): layer = self.mMap.layerAt(i) x = layer.layerType() if x==Layer.TileLayerType: tileLayer = layer self.mUndoStack.push(ResizeTileLayer(self, tileLayer, size, offset)) elif x==Layer.ObjectGroupType: objectGroup = layer # Remove objects that will fall outside of the map for o in objectGroup.objects(): if (not visibleIn(visibleArea, o, self.mRenderer)): self.mUndoStack.push(RemoveMapObject(self, o)) else: oldPos = o.position() newPos = oldPos + pixelOffset self.mUndoStack.push(MoveMapObject(self, newPos, oldPos)) elif x==Layer.ImageLayerType: # Currently not adjusted when resizing the map break self.mUndoStack.push(ResizeMap(self, size)) self.mUndoStack.push(ChangeSelectedArea(self, movedSelection)) self.mUndoStack.endMacro() # TODO: Handle layers that don't match the map size correctly ## # Offsets the layers at \a layerIndexes by \a offset, within \a bounds, # and optionally wraps on the X or Y axis. ## def offsetMap(self, layerIndexes, offset, bounds, wrapX, wrapY): if (layerIndexes.empty()): return if (layerIndexes.size() == 1): self.mUndoStack.push(OffsetLayer(self, layerIndexes.first(), offset, bounds, wrapX, wrapY)) else: self.mUndoStack.beginMacro(self.tr("Offset Map")) for layerIndex in layerIndexes: self.mUndoStack.push(OffsetLayer(self, layerIndex, offset, bounds, wrapX, wrapY)) self.mUndoStack.endMacro() ## # Flips the selected objects in the given \a direction. ## def flipSelectedObjects(self, direction): if (self.mSelectedObjects.isEmpty()): return self.mUndoStack.push(FlipMapObjects(self, self.mSelectedObjects, direction)) ## # Rotates the selected objects. ## def rotateSelectedObjects(self, direction): if (self.mSelectedObjects.isEmpty()): return self.mUndoStack.beginMacro(self.tr("Rotate %n Object(s)", "", self.mSelectedObjects.size())) # TODO: Rotate them properly as a group for mapObject in self.mSelectedObjects: oldRotation = mapObject.rotation() newRotation = oldRotation if (direction == RotateDirection.RotateLeft): newRotation -= 90 if (newRotation < -180): newRotation += 360 else: newRotation += 90 if (newRotation > 180): newRotation -= 360 self.mUndoStack.push(RotateMapObject(self, mapObject, newRotation, oldRotation)) self.mUndoStack.endMacro() ## # Adds a layer of the given type to the top of the layer stack. After adding # the new layer, emits editLayerNameRequested(). ## def addLayer(self, layerType): layer = None name = QString() x = layerType if x==Layer.TileLayerType: name = self.tr("Tile Layer %d"%(self.mMap.tileLayerCount() + 1)) layer = TileLayer(name, 0, 0, self.mMap.width(), self.mMap.height()) elif x==Layer.ObjectGroupType: name = self.tr("Object Layer %d"%(self.mMap.objectGroupCount() + 1)) layer = ObjectGroup(name, 0, 0, self.mMap.width(), self.mMap.height()) elif x==Layer.ImageLayerType: name = self.tr("Image Layer %d"%(self.mMap.imageLayerCount() + 1)) layer = ImageLayer(name, 0, 0, self.mMap.width(), self.mMap.height()) index = self.mMap.layerCount() self.mUndoStack.push(AddLayer(self, index, layer)) self.setCurrentLayerIndex(index) self.editLayerNameRequested.emit() ## # Duplicates the currently selected layer. ## def duplicateLayer(self): if (self.mCurrentLayerIndex == -1): return duplicate = self.mMap.layerAt(self.mCurrentLayerIndex).clone() duplicate.setName(self.tr("Copy of %s"%duplicate.name())) index = self.mCurrentLayerIndex + 1 cmd = AddLayer(self, index, duplicate) cmd.setText(self.tr("Duplicate Layer")) self.mUndoStack.push(cmd) self.setCurrentLayerIndex(index) ## # Merges the currently selected layer with the layer below. This only works # when the layers can be merged. # # \see Layer.canMergeWith ## def mergeLayerDown(self): if (self.mCurrentLayerIndex < 1): return upperLayer = self.mMap.layerAt(self.mCurrentLayerIndex) lowerLayer = self.mMap.layerAt(self.mCurrentLayerIndex - 1) if (not lowerLayer.canMergeWith(upperLayer)): return merged = lowerLayer.mergedWith(upperLayer) self.mUndoStack.beginMacro(self.tr("Merge Layer Down")) self.mUndoStack.push(AddLayer(self, self.mCurrentLayerIndex - 1, merged)) self.mUndoStack.push(RemoveLayer(self, self.mCurrentLayerIndex)) self.mUndoStack.push(RemoveLayer(self, self.mCurrentLayerIndex)) self.mUndoStack.endMacro() ## # Moves the given layer up. Does nothing when no valid layer index is # given. ## def moveLayerUp(self, index): if index<0 or index>=self.mMap.layerCount() - 1: return self.mUndoStack.push(MoveLayer(self, index, MoveLayer.Up)) ## # Moves the given layer down. Does nothing when no valid layer index is # given. ## def moveLayerDown(self, index): if index<1 or index>=self.mMap.layerCount(): return self.mUndoStack.push(MoveLayer(self, index, MoveLayer.Down)) ## # Removes the given layer. ## def removeLayer(self, index): if index<0 or index>=self.mMap.layerCount(): return self.mUndoStack.push(RemoveLayer(self, index)) ## # Show or hide all other layers except the layer at the given index. # If any other layer is visible then all layers will be hidden, otherwise # the layers will be shown. ## def toggleOtherLayers(self, index): self.mLayerModel.toggleOtherLayers(index) ## # Adds a tileset to this map at the given \a index. Emits the appropriate # signal. ## def insertTileset(self, index, tileset): self.tilesetAboutToBeAdded.emit(index) self.mMap.insertTileset(index, tileset) tilesetManager = TilesetManager.instance() tilesetManager.addReference(tileset) self.tilesetAdded.emit(index, tileset) ## # Removes the tileset at the given \a index from this map. Emits the # appropriate signal. # # \warning Does not make sure that any references to tiles in the removed # tileset are cleared. ## def removeTilesetAt(self, index): self.tilesetAboutToBeRemoved.emit(index) tileset = self.mMap.tilesets().at(index) if (tileset == self.mCurrentObject or isFromTileset(self.mCurrentObject, tileset)): self.setCurrentObject(None) self.mMap.removeTilesetAt(index) self.tilesetRemoved.emit(tileset) tilesetManager = TilesetManager.instance() tilesetManager.removeReference(tileset) def moveTileset(self, _from, to): if (_from == to): return tileset = self.mMap.tilesets().at(_from) self.mMap.removeTilesetAt(_from) self.mMap.insertTileset(to, tileset) self.tilesetMoved.emit(_from, to) def setTilesetFileName(self, tileset, fileName): tileset.setFileName(fileName) self.tilesetFileNameChanged.emit(tileset) def setTilesetName(self, tileset, name): tileset.setName(name) self.tilesetNameChanged.emit(tileset) def setTilesetTileOffset(self, tileset, tileOffset): tileset.setTileOffset(tileOffset) self.mMap.recomputeDrawMargins() self.tilesetTileOffsetChanged.emit(tileset) def duplicateObjects(self, objects): if (objects.isEmpty()): return self.mUndoStack.beginMacro(self.tr("Duplicate %n Object(s)", "", objects.size())) clones = QList() for mapObject in objects: clone = mapObject.clone() clones.append(clone) self.mUndoStack.push(AddMapObject(self, mapObject.objectGroup(), clone)) self.mUndoStack.endMacro() self.setSelectedObjects(clones) def removeObjects(self, objects): if (objects.isEmpty()): return self.mUndoStack.beginMacro(self.tr("Remove %n Object(s)", "", objects.size())) for mapObject in objects: self.mUndoStack.push(RemoveMapObject(self, mapObject)) self.mUndoStack.endMacro() def moveObjectsToGroup(self, objects, objectGroup): if (objects.isEmpty()): return self.mUndoStack.beginMacro(self.tr("Move %n Object(s) to Layer", "", objects.size())) for mapObject in objects: if (mapObject.objectGroup() == objectGroup): continue self.mUndoStack.push(MoveMapObjectToGroup(self, mapObject, objectGroup)) self.mUndoStack.endMacro() def setProperty(self, object, name, value): hadProperty = object.hasProperty(name) object.setProperty(name, value) if (hadProperty): self.propertyChanged.emit(object, name) else: self.propertyAdded.emit(object, name) def setProperties(self, object, properties): object.setProperties(properties) self.propertiesChanged.emit(object) def removeProperty(self, object, name): object.removeProperty(name) self.propertyRemoved.emit(object, name) ## # Returns the layer model. Can be used to modify the layer stack of the # map, and to display the layer stack in a view. ## def layerModel(self): return self.mLayerModel def mapObjectModel(self): return self.mMapObjectModel def terrainModel(self): return self.mTerrainModel ## # Returns the map renderer. ## def renderer(self): return self.mRenderer ## # Creates the map renderer. Should be called after changing the map # orientation. ## def createRenderer(self): if (self.mRenderer): del self.mRenderer x = self.mMap.orientation() if x==Map.Orientation.Isometric: self.mRenderer = IsometricRenderer(self.mMap) elif x==Map.Orientation.Staggered: self.mRenderer = StaggeredRenderer(self.mMap) elif x==Map.Orientation.Hexagonal: self.mRenderer = HexagonalRenderer(self.mMap) else: self.mRenderer = OrthogonalRenderer(self.mMap) ## # Returns the undo stack of this map document. Should be used to push any # commands on that modify the map. ## def undoStack(self): return self.mUndoStack ## # Returns the selected area of tiles. ## def selectedArea(self): return QRegion(self.mSelectedArea) ## # Sets the selected area of tiles. ## def setSelectedArea(self, selection): if (self.mSelectedArea != selection): oldSelectedArea = self.mSelectedArea self.mSelectedArea = selection self.selectedAreaChanged.emit(self.mSelectedArea, oldSelectedArea) ## # Returns the list of selected objects. ## def selectedObjects(self): return self.mSelectedObjects ## # Sets the list of selected objects, emitting the selectedObjectsChanged # signal. ## def setSelectedObjects(self, selectedObjects): if selectedObjects.nequal(self.mSelectedObjects): self.mSelectedObjects = selectedObjects self.selectedObjectsChanged.emit() if (selectedObjects.size() == 1): self.setCurrentObject(selectedObjects.first()) ## # Returns the list of selected tiles. ## def selectedTiles(self): return self.mSelectedTiles def setSelectedTiles(self, selectedTiles): self.mSelectedTiles = selectedTiles self.selectedTilesChanged.emit() def currentObject(self): return self.mCurrentObject def setCurrentObject(self, object): if (object == self.mCurrentObject): return self.mCurrentObject = object self.currentObjectChanged.emit([object]) def currentObjects(self): objects = QList() if (self.mCurrentObject): if (self.mCurrentObject.typeId() == Object.MapObjectType and not self.mSelectedObjects.isEmpty()): for mapObj in self.mSelectedObjects: objects.append(mapObj) elif (self.mCurrentObject.typeId() == Object.TileType and not self.mSelectedTiles.isEmpty()): for tile in self.mSelectedTiles: objects.append(tile) else: objects.append(self.mCurrentObject) return objects def unifyTilesets(self, *args): l = len(args) if l==1: ## # Makes sure the all tilesets which are used at the given \a map will be # present in the map document. # # To reach the aim, all similar tilesets will be replaced by the version # in the current map document and all missing tilesets will be added to # the current map document. # # \warning This method assumes that the tilesets in \a map are managed by # the TilesetManager! ## map = args[0] undoCommands = QList() existingTilesets = self.mMap.tilesets() tilesetManager = TilesetManager.instance() # Add tilesets that are not yet part of this map for tileset in map.tilesets(): if (existingTilesets.contains(tileset)): continue replacement = tileset.findSimilarTileset(existingTilesets) if (not replacement): undoCommands.append(AddTileset(self, tileset)) continue # Merge the tile properties sharedTileCount = min(tileset.tileCount(), replacement.tileCount()) for i in range(sharedTileCount): replacementTile = replacement.tileAt(i) properties = replacementTile.properties() properties.merge(tileset.tileAt(i).properties()) undoCommands.append(ChangeProperties(self, self.tr("Tile"), replacementTile, properties)) map.replaceTileset(tileset, replacement) tilesetManager.addReference(replacement) tilesetManager.removeReference(tileset) if (not undoCommands.isEmpty()): self.mUndoStack.beginMacro(self.tr("Tileset Changes")) for command in undoCommands: self.mUndoStack.push(command) self.mUndoStack.endMacro() elif l==2: map, missingTilesets = args existingTilesets = self.mMap.tilesets() tilesetManager = TilesetManager.instance() for tileset in map.tilesets(): # tileset already added if existingTilesets.contains(tileset): continue replacement = tileset.findSimilarTileset(existingTilesets) # tileset not present and no replacement tileset found if not replacement: if not missingTilesets.contains(tileset): missingTilesets.append(tileset) continue # replacement tileset found, change given map map.replaceTileset(tileset, replacement) tilesetManager.addReference(replacement) tilesetManager.removeReference(tileset) ## # Emits the map changed signal. This signal should be emitted after changing # the map size or its tile size. ## def emitMapChanged(self): self.mapChanged.emit() ## # Emits the region changed signal for the specified region. The region # should be in tile coordinates. This method is used by the TilePainter. ## def emitRegionChanged(self, region, layer): self.regionChanged.emit(region, layer) ## # Emits the region edited signal for the specified region and tile layer. # The region should be in tile coordinates. This should be called from # all map document changing classes which are triggered by user input. ## def emitRegionEdited(self, region, layer): self.regionEdited.emit(region, layer) def emitTileLayerDrawMarginsChanged(self, layer): self.tileLayerDrawMarginsChanged.emit(layer) ## # Emits the tileset changed signal. This signal is currently used when adding # or removing tiles from a tileset. # # @todo Emit more specific signals. ## def emitTilesetChanged(self, tileset): self.tilesetChanged.emit(tileset) ## # Emits the signal notifying about the terrain probability of a tile changing. ## def emitTileProbabilityChanged(self, tile): self.tileProbabilityChanged.emit(tile) ## # Emits the signal notifying tileset models about changes to tile terrain # information. All the \a tiles need to be from the same tileset. ## def emitTileTerrainChanged(self, tiles): if (not tiles.isEmpty()): self.tileTerrainChanged.emit(tiles) ## # Emits the signal notifying the TileCollisionEditor about the object group # of a tile changing. ## def emitTileObjectGroupChanged(self, tile): self.tileObjectGroupChanged.emit(tile) ## # Emits the signal notifying about the animation of a tile changing. ## def emitTileAnimationChanged(self, tile): self.tileAnimationChanged.emit(tile) ## # Emits the objectGroupChanged signal, should be called when changing the # color or drawing order of an object group. ## def emitObjectGroupChanged(self, objectGroup): self.objectGroupChanged.emit(objectGroup) ## # Emits the imageLayerChanged signal, should be called when changing the # image or the transparent color of an image layer. ## def emitImageLayerChanged(self, imageLayer): self.imageLayerChanged.emit(imageLayer) ## # Emits the editLayerNameRequested signal, to get renamed. ## def emitEditLayerNameRequested(self): self.editLayerNameRequested.emit() ## # Emits the editCurrentObject signal, which makes the Properties window become # visible and take focus. ## def emitEditCurrentObject(self): self.editCurrentObject.emit() ## # Before forwarding the signal, the objects are removed from the list of # selected objects, triggering a selectedObjectsChanged signal when # appropriate. ## def onObjectsRemoved(self, objects): self.deselectObjects(objects) self.objectsRemoved.emit(objects) def onMapObjectModelRowsInserted(self, parent, first, last): objectGroup = self.mMapObjectModel.toObjectGroup(parent) if (not objectGroup): # we're not dealing with insertion of objects return self.objectsInserted.emit(objectGroup, first, last) self.onMapObjectModelRowsInsertedOrRemoved(parent, first, last) def onMapObjectModelRowsInsertedOrRemoved(self, parent, first, last): objectGroup = self.mMapObjectModel.toObjectGroup(parent) if (not objectGroup): return # Inserting or removing objects changes the index of any that come after lastIndex = objectGroup.objectCount() - 1 if (last < lastIndex): self.objectsIndexChanged.emit(objectGroup, last + 1, lastIndex) def onObjectsMoved(self, parent, start, end, destination, row): if (parent != destination): return objectGroup = self.mMapObjectModel.toObjectGroup(parent) # Determine the full range over which object indexes changed first = min(start, row) last = max(end, row - 1) self.objectsIndexChanged.emit(objectGroup, first, last) def onLayerAdded(self, index): self.layerAdded.emit(index) # Select the first layer that gets added to the map if (self.mMap.layerCount() == 1): self.setCurrentLayerIndex(0) def onLayerAboutToBeRemoved(self, index): layer = self.mMap.layerAt(index) if (layer == self.mCurrentObject): self.setCurrentObject(None) # Deselect any objects on this layer when necessary og = layer if type(og) == ObjectGroup: self.deselectObjects(og.objects()) self.layerAboutToBeRemoved.emit(index) def onLayerRemoved(self, index): # Bring the current layer index to safety currentLayerRemoved = self.mCurrentLayerIndex == self.mMap.layerCount() if (currentLayerRemoved): self.mCurrentLayerIndex = self.mCurrentLayerIndex - 1 self.layerRemoved.emit(index) # Emitted after the layerRemoved signal so that the MapScene has a chance # of synchronizing before adapting to the newly selected index if (currentLayerRemoved): self.currentLayerIndexChanged.emit(self.mCurrentLayerIndex) def onTerrainRemoved(self, terrain): if (terrain == self.mCurrentObject): self.setCurrentObject(None) def setFileName(self, fileName): if (self.mFileName == fileName): return oldFileName = self.mFileName self.mFileName = fileName self.fileNameChanged.emit(fileName, oldFileName) def deselectObjects(self, objects): # Unset the current object when it was part of this list of objects if (self.mCurrentObject and self.mCurrentObject.typeId() == Object.MapObjectType): if (objects.contains(self.mCurrentObject)): self.setCurrentObject(None) removedCount = 0 for object in objects: removedCount += self.mSelectedObjects.removeAll(object) if (removedCount > 0): self.selectedObjectsChanged.emit() def disconnect(self): try: super().disconnect() except: pass
class Document(QWidget, ProjectDocument): mark_item_created = pyqtSignal(MarkItem) added_mark_item = pyqtSignal(Project, MarkItem) browser_result_signal = pyqtSignal(bool) selected_mark_item_changed = pyqtSignal(MarkItem) def __init__(self, gadget, toolbar_gadget, file_name=None, project_name=None, image_path=None, person_name=None, parent=None, eraser_size=3, eraser_option=SelectionOptionToolBar.Subtract): super(Document, self).__init__(parent) ProjectDocument.__init__(self, parent=parent) self._writer_format = ProjectFormat() self._reader_format = ProjectFormat() self._export_format = ProjectFormat() self._mark_item_to_outline_item = {} self._modifier = False self._project = Project(image_path, file_name, project_name, person_name) self._image_path = image_path if image_path else self._project.image_path self._current_tool = gadget self._selection_option = toolbar_gadget self._eraser_size = eraser_size self._eraser_option = eraser_option self.__current_index = -1 self.__mouse_press_index = -1 self.__mouse_press_offset = QPoint() self.__resize_handel_pressed = False self.__undo_stack = QUndoStack(self) self._selection_item = None self._history_widget = None self._history_project_manager = None self._mark_item_manager = MarkItemManager() self._mark_item_manager.selected_item_changed.connect( self.selected_mark_item_changed) # # 创建场景 self._workbench_scene.setObjectName("workbench_scene") self._is_big_img = False # if self._is_big_img: # self.workbench_view = LoadIMGraphicsView(self._mark_item_manager, gadget, toolbar_gadget, eraser_size, # image_path, self._workbench_scene, parent=self) # else: self.workbench_view = GraphicsViewTest(self._mark_item_manager, gadget, toolbar_gadget, eraser_size, parent=self) # 把场景添加到视图中 self.workbench_view.setScene(self._workbench_scene) self.workbench_view.setObjectName("workbench_view") self.workbench_view.setContentsMargins(0, 0, 0, 0) self.workbench_view.setBackgroundBrush(QColor(147, 147, 147)) # 布局 self.tab_vertical_layout = QVBoxLayout(self) self._splitter1 = QSplitter(self) self._splitter1.setStyleSheet("margin: 0px") self._splitter1.addWidget(self.workbench_view) self._splitter2 = QSplitter(self) self._splitter2.setOrientation(Qt.Vertical) self._splitter2.setStyleSheet("margin: 0px") self._splitter2.addWidget(self._splitter1) self.tab_vertical_layout.addWidget(self._splitter2) self.tab_vertical_layout.setContentsMargins(0, 0, 0, 0) # 当前选择小工具 self.change_gadget(gadget) # 信号接收 self.workbench_view.border_moved_signal.connect(self.border_moved) self.workbench_view.border_created.connect(self.created_border) self.workbench_view.about_to_create_border.connect( self.about_to_create_border) self.workbench_view.eraser_action_signal.connect(self.eraser_action) if all([image_path, project_name, file_name]) and not self._is_big_img: self.create_document() @property def is_big_img(self): return self._is_big_img def about_to_cmp(self, project_documents: ProjectDocument = None): if not self._history_widget: self._history_project_manager = HistoryProjectManager( project_documents) self._history_widget = Thumbnail(self._history_project_manager, self) self._history_project_manager.set_scene( self._history_widget.current_project()) self._splitter1.addWidget(self._history_project_manager.get_view()) self._splitter2.addWidget(self._history_widget) self.workbench_view.set_is_comparing(True) self._history_project_manager.get_view().set_is_comparing(True) self._history_widget.selected_project_changed.connect( self._selected_history_project_changed) self._history_widget.close_event_signal.connect( self._toggle_cmp_history) self._history_widget.synchronize_changed_signal.connect( self._toggle_synchronize_view) items = project_documents[0].project().get_mark_items() for item in items: self._project.add_mark_item(item) else: self._toggle_cmp_history(True) self._history_widget.setHidden(False) if True: self.connect_to_synchronize_view() def had_cmp(self): return bool(self._history_widget) def _toggle_synchronize_view(self, is_synchronize: bool): if is_synchronize: self.connect_to_synchronize_view() else: self.disconnect_to_asynchronous_view() def connect_to_synchronize_view(self): self._history_project_manager.synchronize_with_origin_view( self.workbench_view) self._history_project_manager.get_view().connect_to_synchronize_with( self.workbench_view) self.workbench_view.connect_to_synchronize_with( self._history_project_manager.get_view()) def disconnect_to_asynchronous_view(self): self._history_project_manager.get_view( ).disconnect_to_asynchronous_with(self.workbench_view) self.workbench_view.disconnect_to_asynchronous_with( self._history_project_manager.get_view()) def _toggle_cmp_history(self, is_on: bool): self._history_project_manager.hidden_view(not is_on) self.workbench_view.set_is_comparing(is_on) self._history_project_manager.get_view().set_is_comparing(is_on) def _selected_history_project_changed(self, project_doc: ProjectDocument): self._history_project_manager.set_scene(project_doc) self._history_project_manager.synchronize_with_origin_view( self.workbench_view) def modifier(self): return not self.__undo_stack.isClean() def set_project(self, project: Project): self._project = project if not self._image_path: self._image_path = project.image_path if not self._is_big_img: self.load_document(self._image_path) for mark_item in self._project.get_mark_items(): self.add_mark_item(mark_item) def set_current_mark_item(self, mark_item: MarkItem): """""" if not mark_item: return item = [ item for item in self._workbench_scene.items() if isinstance(item, OutlineItem) and item.mark_item() == mark_item ] if item: self._mark_item_manager.set_selected_item(item[0]) self.workbench_view.centerOn(item[0]) def delete_mark_item(self, mark_item: [MarkItem, OutlineItem]): if not mark_item: return if isinstance(mark_item, MarkItem): if self._mark_item_manager.selected_mark_item().mark_item( ) == mark_item: self._mark_item_manager.set_selected_item(None) self._project.remove_mark_item(mark_item) item = [ item for item in self._workbench_scene.items() if isinstance(item, OutlineItem) and item.mark_item() == mark_item ] if item: self._mark_item_manager.unregister_mark_item( mark_item.item_name) self._workbench_scene.removeItem(item[0]) del item[0] elif isinstance(mark_item, OutlineItem): if self._mark_item_manager.selected_mark_item() == mark_item: self._mark_item_manager.set_selected_item(None) self._project.remove_mark_item(mark_item.mark_item()) self._mark_item_manager.unregister_mark_item(mark_item.item_name) self._workbench_scene.removeItem(mark_item) del mark_item def project(self) -> Project: return self._project def project_name(self): return self._project.project_name def undo_stack(self): return self.__undo_stack def create_document(self): self.load_document(self._image_path) self.save_project() def save_project(self): self.writer_format.save_project(self._project) self.__undo_stack.clear() def export_result(self, path, progress): self.writer_format.export_result(path, self._project, self._image.size(), self) def get_file_name(self): return self._project.project_full_path() def get_project_name(self): return self._project.parent() def about_to_create_border(self): if self._selection_option == SelectionOptionToolBar.Replace: self._workbench_scene.removeItem(self._selection_item) self._selection_item = None def cancel_selection(self): self._workbench_scene.removeItem(self._selection_item) self._selection_item.disconnect() self._selection_item = None def selection_as_mark_item(self): """TODO""" def created_border(self, border: SelectionItem): if self._selection_option == SelectionOptionToolBar.Replace: self._workbench_scene.removeItem(self._selection_item) self._selection_item = border self.__undo_stack.push( AddSelectionItem(self._workbench_scene, self._selection_item)) elif self._selection_option == SelectionOptionToolBar.Subtract: if self._selection_item: self._selection_item -= border elif self._selection_option == SelectionOptionToolBar.Add: if not self._selection_item: self._selection_item = border else: self._selection_item += border elif self._selection_option == SelectionOptionToolBar.Intersect: if self._selection_item: self._selection_item &= border if self._selection_item: if self._selection_item.is_empty(): self._workbench_scene.removeItem(self._selection_item) self._selection_item = None return self.workbench_view.view_zoom_signal.connect( self._selection_item.set_pen_width_by_scale) self._selection_item.cancel_selection_signal.connect( self.cancel_selection) self._selection_item.as_mark_item_signal.connect( self.selection_as_mark_item) self._selection_item.reverse_select_signal.connect( self._select_reverser_path) def add_border_item(self, item: SelectionItem): self.__undo_stack.push(AddItemCommand(self._workbench_scene, item)) def border_moved(self, item: SelectionItem): self.__undo_stack.push(MoveItemCommand(item)) def change_toolbar_gadget(self, toolbar_gadget: QAction): self._selection_option = toolbar_gadget.data() def change_eraser_option(self, option_action: QAction): self._eraser_option = option_action.data() def change_gadget(self, tool: QAction): if isinstance(tool, QAction): tool = tool.data() self.workbench_view.set_gadget(tool) if tool == ToolsToolBar.BrowserImageTool: self.browser_result() self.browser_result_signal.emit(True) else: if self._current_tool == ToolsToolBar.BrowserImageTool: self.end_browser() self.browser_result_signal.emit(False) self._current_tool = tool def eraser_size_changed(self, eraser_size: int): self._eraser_size = eraser_size self.workbench_view.set_eraser_size(eraser_size) def browser_result(self): self.workbench_view.setBackgroundBrush(QColor(Qt.black)) self._pixmap_item.setVisible(False) if self.workbench_view.is_comparing(): self._history_project_manager.browser_result() if self._selection_item: self._selection_item.setVisible(False) def end_browser(self): self.workbench_view.setBackgroundBrush(QColor(147, 147, 147)) self._pixmap_item.setVisible(True) if self.workbench_view.is_comparing(): self._history_project_manager.end_browser() if self._selection_item: self._selection_item.setVisible(True) def get_sub_image_in(self, item: SelectionItem) -> [QImage, None]: rect = item.rectangle() if self.is_big_img: """""" # slide_helper = SlideHelper(self.project().image_path) # image_from_rect = ImgFromRect(rect, slide_helper) # image_from_rect = image_from_rect.area_img # return image_from_rect else: rect_sub_image = self._image.copy(rect) polygon_path = item.get_path() polygon_sub_image = rect_sub_image for row in range(0, rect.width()): for clo in range(0, rect.height()): point = QPoint(row, clo) if not polygon_path.contains(point): polygon_sub_image.setPixel(point, 0) return polygon_sub_image def ai_delete_outline(self, detect_policy): result_numpy_array = None width_num_array = None if not self._selection_item: image = self._image else: image = self._image.copy(self._selection_item.rectangle()) if detect_policy == 5: for h in range(0, image.height(), 256): for w in range(0, image.width(), 256): image_ = self._image.copy(QRect(w, h, 255, 255)) image_ = qimage2numpy(image_) result = detect_one(image_) numpy_array = mat_to_img(result) if width_num_array is not None: width_num_array = np.hstack( (width_num_array, numpy_array)) else: width_num_array = numpy_array if result_numpy_array is not None: result_numpy_array = np.vstack( (result_numpy_array, width_num_array)) else: result_numpy_array = width_num_array width_num_array = None print(result_numpy_array.shape) return result_numpy_array def _get_outlines(self, numpy_array, detect_policy): outline_path1 = QPainterPath() outline_path2 = QPainterPath() outline1, outline2 = detect_outline(detect_policy, numpy_array, drop_area=80) for array in outline1: sub_path = [] for point in array[0]: point = self._selection_item.mapToScene( point[0][0], point[0][1]) sub_path.append(point) polygon = QPolygonF(sub_path) path = QPainterPath() path.addPolygon(polygon) outline_path1 += path for array in outline2: sub_path = [] for point in array[0]: point = self._selection_item.mapToScene( point[0][0], point[0][1]) sub_path.append(point) polygon = QPolygonF(sub_path) path = QPainterPath() path.addPolygon(polygon) outline_path2 += path return outline_path1, outline_path2 def _get_outline_by_no_selection(self, numpy_array, detect_policy): outline_path1 = QPainterPath() outline_path2 = QPainterPath() outline1, outline2 = detect_outline(detect_policy, numpy_array, drop_area=80) for array in outline1: sub_path = [] for point in array[0]: point = QPoint(point[0][0], point[0][1]) sub_path.append(point) polygon = QPolygonF(sub_path) path = QPainterPath() path.addPolygon(polygon) outline_path1 += path for array in outline2: sub_path = [] for point in array[0]: point = QPoint(point[0][0], point[0][1]) sub_path.append(point) polygon = QPolygonF(sub_path) path = QPainterPath() path.addPolygon(polygon) outline_path2 += path return outline_path1, outline_path2 def _to_create_mark_item(self, outline_path1, outline_path2): use_outline1_flag = True if not outline_path1.isEmpty(): self.create_mark_item(outline_path1) elif not outline_path2.isEmpty(): self.create_mark_item(outline_path2) use_outline1_flag = False if self._selection_item: self._selection_item.setFlag(QGraphicsItem.ItemIsMovable, False) if use_outline1_flag and not outline_path2.isEmpty(): self._selection_item.set_reverser_path(outline_path2) def detect_outline(self, detect_policy): """ 将选中的选区对应的部分图片copy出来,然后转为ndarray类型 用来转为OpenCV识别轮廓的输入数据 :param detect_policy: 用哪种识别算法识别轮廓 :return: None """ if detect_policy >= 5: numpy_array = self.ai_delete_outline(detect_policy) outline_path1, outline_path2 = self._get_outline_by_no_selection( numpy_array, detect_policy) if not self._selection_item: self._selection_item = SelectionItem( QPoint(0, 0), self._workbench_scene, self.workbench_view.transform().m11()) path = QPainterPath() path.addRect( QRectF(0, 0, self._image.width(), self._image.height())) self._selection_item.set_item_path_by_path(path) self._selection_item.reverse_select_signal.connect( self._select_reverser_path) self._to_create_mark_item(outline_path1, outline_path2) return if not self._selection_item: QMessageBox.warning(self, "警告", "没有选择区域!") return if isinstance(self._selection_item, SelectionItem): outline_path1 = QPainterPath() outline_path2 = QPainterPath() if detect_policy == 4: self._workbench_scene.removeItem(self._selection_item) outline_path1 = self._selection_item.mapToScene( self._selection_item.get_path()) self._selection_item = None else: sub_img = self.get_sub_image_in(self._selection_item) if sub_img is None: return if isinstance(sub_img, QImage): sub_img = qimage2numpy(sub_img) outline_path1, outline_path2 = self._get_outlines( sub_img, detect_policy) self._to_create_mark_item(outline_path1, outline_path2) def correction_outline(self, option): """""" if not self._selection_item: return mark_items = [ item for item in self._workbench_scene.items( self._selection_item.get_scene_path()) if isinstance(item, OutlineItem) ] for mark_item in mark_items: if mark_item.locked(): continue elif option == 1: mark_item += self._selection_item self._mark_item_manager.set_selected_item(mark_item) break elif option == 2: mark_item -= self._selection_item self._mark_item_manager.set_selected_item(mark_item) break self._workbench_scene.removeItem(self._selection_item) self._selection_item = None def eraser_action(self, eraser_area: SelectionItem): if self._selection_item: eraser_area &= self._selection_item if eraser_area.is_empty(): return mark_items = [ item for item in self._workbench_scene.items( eraser_area.get_scene_path()) if isinstance(item, OutlineItem) ] # if self._eraser_option == SelectionOptionToolBar.Add: # selected_item = self._mark_item_manager.selected_mark_item() # if selected_item in mark_items: # selected_item += eraser_area # return for item in mark_items: if item.locked(): continue item -= eraser_area self._workbench_scene.removeItem(eraser_area) del eraser_area def create_mark_item(self, outline: QPainterPath): item_name = self._mark_item_manager.get_unique_mark_item_name() new_mark_item = MarkItem(list(self._project.persons), item_name=item_name, outline_path=outline) self._project.add_mark_item(new_mark_item) self.add_mark_item(new_mark_item, True) def add_mark_item(self, mark_item: MarkItem, new_item=False): item = OutlineItem(mark_item, self._workbench_scene, self.workbench_view.transform().m11()) flag = True if new_item: flag = self.detect_intersect_with_others(item) if flag: self._mark_item_to_outline_item[mark_item] = item self.browser_result_signal.connect(item.is_browser_result) self.workbench_view.view_zoom_signal.connect( item.set_pen_width_by_scale) self._mark_item_manager.register_mark_item(item, mark_item.item_name) self.added_mark_item.emit(self._project, mark_item) self._mark_item_manager.set_selected_item(item) def _select_reverser_path(self): if self._selection_item: item = self._project.get_mark_items()[-1] reverse_path = self._selection_item.get_reverse_path() self._selection_item.set_reverser_path(item.get_outline()) item.set_outline(reverse_path) def detect_intersect_with_others(self, new_item: OutlineItem): selection_path = new_item.get_scene_path() mark_items = [ item for item in self._workbench_scene.items(selection_path) if isinstance(item, OutlineItem) ] for mark_item in mark_items: if mark_item != new_item: new_item -= mark_item if new_item.get_path().isEmpty(): self._workbench_scene.removeItem(new_item) del new_item return False else: new_item.get_path().closeSubpath() return True def paint_make_item(self, mark_item: MarkItem): pen = QPen() pen.setWidth(1) pen.setColor(Qt.yellow) self._workbench_scene.addPath(mark_item.draw_path(), pen) @property def writer_format(self): return self._reader_format @writer_format.setter def writer_format(self, new_writer_format): self._writer_format = new_writer_format @property def reader_format(self): return self._reader_format @reader_format.setter def reader_format(self, new_reader_format): self._reader_format = new_reader_format @property def export_format(self): return self._export_format @export_format.setter def export_format(self, new_export_format): self._export_format = new_export_format
class GMPage: @property def document(self): return self._doc @property def index(self): return self._index @property def page_image(self): return self._page_image @property def drawing_image(self): return self._drawing_image @property def command_count(self): return self._undo_stack.count() @property def can_undo(self): return self._undo_stack.canUndo() @property def can_redo(self): return self._undo_stack.canRedo() def __init__(self, document, mu_page, index): assert isinstance(document, GMDoc) assert isinstance(mu_page, fitz.Page) assert type(index) is int self._doc = document self._index = index self._mu_page = mu_page # zoom in to get higher resolution pixmap # todo: add this param to preferences dialog zoom = 2 matrix = fitz.Matrix(zoom, zoom) page_data = self._mu_page.getPixmap(matrix=matrix, alpha=False).getImageData() self._undo_stack = QUndoStack() self._page_image = QPixmap() self._page_image.loadFromData(page_data) self._drawing_image = generateDrawingPixmap(self._page_image) self._last_drawing_hash = None self.captureState() def getData(self, alpha=False): return self._mu_page.getPixmap(alpha=alpha).getImageData() def getSize(self): return self._page_image.size() def setDrawingPixmap(self, pixmap): self._drawing_image = pixmap def getMergedPixmap(self): result = QPixmap(self.getSize()) result.fill(QColor(255, 255, 255)) painter = QPainter(result) painter.setRenderHint(QPainter.HighQualityAntialiasing, True) painter.drawPixmap(QPoint(0, 0), self._page_image) painter.drawPixmap(QPoint(0, 0), self._drawing_image) painter.end() return result def getUndoAction(self, parent): return self._undo_stack.createUndoAction(parent) def getRedoAction(self, parent): return self._undo_stack.createRedoAction(parent) def pushCommand(self, command): assert isinstance(command, QUndoCommand) self._undo_stack.push(command) def captureState(self): self._last_drawing_hash = self._drawing_image.cacheKey() self._undo_stack.setClean() def changed(self): if self._undo_stack.isClean(): return False return self._undo_stack.count() > 0 def undo(self): self._undo_stack.undo() def redo(self): self._undo_stack.redo()
class AbstractPage(QObject): sigChanged = pyqtSignal() def __init__(self, parent: MasterDocument): super().__init__() self._objs = set() self._parent = parent self.undoStack = QUndoStack() self._name = "untitled" @property def name(self): return self._name @name.setter def name(self, n): self._name = n self.sigChanged.emit() @property def parentDoc(self): return self._parent def isModified(self): return not self.undoStack.isClean() def doCommand(self, cmd): cmd.doc = self self.undoStack.push(cmd) self.sigChanged.emit() def objects(self, objType=None): if not objType: return set(self._objs) return {obj for obj in self._objs if type(obj) is objType} def hasObject(self, obj): return obj in self._objs def findObjsInRect(self, rect: QRect, objType=None): return { obj for obj in self.objects(objType) if rect.intersects(obj.bbox()) } def findObjsNear(self, pt: QPoint, dist=1, objType=None): hitRect = QRect(pt.x() - dist / 2, pt.y() - dist / 2, dist, dist) return { obj for obj in self.findObjsInRect(hitRect, objType) if obj.testHit(pt, dist) } def addObj(self, obj): self._objs.add(obj) self.sigChanged.emit() def removeObj(self, obj): self._objs.remove(obj) self.sigChanged.emit() @pyqtSlot() def undo(self): self.undoStack.undo() self.sigChanged.emit() @pyqtSlot() def redo(self): self.undoStack.redo() self.sigChanged.emit() def fromXml(self, pageNode): raise NotImplementedError() def toXml(self, parentNode): raise NotImplementedError()
class SchmereoMainWindow(QtWidgets.QMainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.ui = uic.loadUi( uifile=pkg_resources.resource_stream("schmereo", "schmereo.ui"), baseinstance=self, ) # Platform-specific semantic keyboard shortcuts cannot be set in Qt Designer self.ui.actionNew.setShortcut(QKeySequence.New) self.ui.actionOpen.setShortcut(QKeySequence.Open) self.ui.actionQuit.setShortcut( QKeySequence.Quit) # no effect on Windows self.ui.actionSave.setShortcut(QKeySequence.Save) self.ui.actionSave_Project_As.setShortcut(QKeySequence.SaveAs) self.ui.actionZoom_In.setShortcuts( [QKeySequence.ZoomIn, "Ctrl+="]) # '=' so I don't need to press SHIFT self.ui.actionZoom_Out.setShortcut(QKeySequence.ZoomOut) # self.recent_files = RecentFileList( open_file_slot=self.load_file, settings_key="recent_files", menu=self.ui.menuRecent_Files, ) # Link views self.shared_camera = Camera() self.ui.leftImageWidget.camera = self.shared_camera self.ui.rightImageWidget.camera = self.shared_camera # self.ui.leftImageWidget.file_dropped.connect(self.load_left_file) self.ui.rightImageWidget.file_dropped.connect(self.load_right_file) # self.ui.leftImageWidget.image.transform.center = FractionalImagePos( -0.5, 0) self.ui.rightImageWidget.image.transform.center = FractionalImagePos( +0.5, 0) # for w in (self.ui.leftImageWidget, self.ui.rightImageWidget): w.messageSent.connect(self.ui.statusbar.showMessage) # self.marker_set = list() self.zoom_increment = 1.10 self.image_saver = ImageSaver(self.ui.leftImageWidget, self.ui.rightImageWidget) # TODO: object for AddMarker tool button tb = self.ui.addMarkerToolButton tb.setDefaultAction(self.ui.actionAdd_Marker) sz = 32 tb.setFixedSize(sz, sz) tb.setIconSize(QtCore.QSize(sz, sz)) hb = self.ui.handModeToolButton hb.setDefaultAction(self.ui.actionHand_Mode) hb.setFixedSize(sz, sz) hb.setIconSize(QtCore.QSize(sz, sz)) _set_action_icon( self.ui.actionAdd_Marker, "schmereo.marker", "crosshair64.png", "crosshair64blue.png", ) _set_action_icon(self.ui.actionHand_Mode, "schmereo", "cursor-openhand20.png") # tb.setDragEnabled(True) # TODO: drag tool button to place marker self.marker_manager = MarkerManager(self) self.aligner = Aligner(self) self.project_file_name = None # self.undo_stack = QUndoStack(self) undo_action = self.undo_stack.createUndoAction(self, '&Undo') undo_action.setShortcuts(QKeySequence.Undo) redo_action = self.undo_stack.createRedoAction(self, '&Redo') redo_action.setShortcuts(QKeySequence.Redo) self.undo_stack.cleanChanged.connect(self.on_undoStack_cleanChanged) # self.ui.menuEdit.insertAction(self.ui.actionAlign_Now, undo_action) self.ui.menuEdit.insertAction(self.ui.actionAlign_Now, redo_action) self.ui.menuEdit.insertSeparator(self.ui.actionAlign_Now) self.clip_box = ClipBox(parent=self, camera=self.shared_camera, images=[i.image for i in self.eye_widgets()]) self.ui.actionResolve_Clip_Box.triggered.connect( self.recenter_clip_box) for w in self.eye_widgets(): w.undo_stack = self.undo_stack w.clip_box = self.clip_box self.clip_box.changed.connect(w.update) self.project_folder = None def check_save(self) -> bool: if self.undo_stack.isClean(): return True # OK to do whatever now result = QMessageBox.warning( self, "The project has been modified.", "The project has been modified.\n" "Do you want to save your changes?", QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save, ) if result == QMessageBox.Save: if self.project_file_name is None: return self.on_actionSave_Project_As_triggered() else: return self.save_project_file(self.project_file_name) elif result == QMessageBox.Discard: return True # OK to do whatever now elif result == QMessageBox.Cancel: return False else: # Unexpected to get here? return False # cancel / abort def closeEvent(self, event: QCloseEvent): if self.check_save(): event.accept() else: event.ignore() def eye_widgets(self): for w in (self.ui.leftImageWidget, self.ui.rightImageWidget): yield w def keyReleaseEvent(self, event: QtGui.QKeyEvent): if event.key() == Qt.Key_Escape: self.marker_manager.set_marker_mode(False) def load_left_file(self, file_name: str) -> None: self.load_file(file_name) def load_right_file(self, file_name: str) -> None: self.load_file(file_name) @QtCore.pyqtSlot(str) def load_file(self, file_name: str) -> bool: result = False self.log_message(f"Loading file {file_name}...") try: image = Image.open(file_name) except OSError: return self.load_project(file_name) result = self.ui.leftImageWidget.load_image(file_name) if result: result = self.ui.rightImageWidget.load_image(file_name) if result: self.ui.leftImageWidget.update() self.ui.rightImageWidget.update() self.recent_files.add_file(file_name) self.project_folder = os.path.dirname(file_name) else: self.log_message(f"ERROR: Image load failed.") return result def load_project(self, file_name): with open(file_name, "r") as fh: data = json.load(fh) self.from_dict(data) for w in self.eye_widgets(): w.update() self.recent_files.add_file(file_name) self.project_file_name = file_name self.project_folder = os.path.dirname(file_name) self.setWindowFilePath(self.project_file_name) self.undo_stack.clear() self.undo_stack.setClean() return True def log_message(self, message: str) -> None: self.ui.statusbar.showMessage(message) @QtCore.pyqtSlot() def on_actionAbout_Schmereo_triggered(self): QtWidgets.QMessageBox.about( self, "About Schmereo", inspect.cleandoc(f""" Schmereo stereograph restoration application Version: {__version__} Author: Christopher M. Bruns Code: https://github.com/cmbruns/schmereo """), ) @QtCore.pyqtSlot() def on_actionAlign_Now_triggered(self): self.clip_box.recenter() self.undo_stack.push(AlignNowCommand(self)) @QtCore.pyqtSlot() def on_actionClear_Markers_triggered(self): self.undo_stack.push(ClearMarkersCommand(*self.eye_widgets())) @QtCore.pyqtSlot() def on_actionNew_triggered(self): if not self.check_save(): return self.project_folder = None self.project_file_name = None self.setWindowFilePath("untitled") self.shared_camera.reset() for w in self.eye_widgets(): w.image.transform.reset() self.undo_stack.clear() self.undo_stack.setClean() @QtCore.pyqtSlot() def on_actionOpen_triggered(self): folder = None if folder is None: folder = self.project_folder if folder is None: folder = "" file_name, file_type = QtWidgets.QFileDialog.getOpenFileName( parent=self, caption="Load Image", directory=folder, filter="Projects and Images (*.json *.tif);;All Files (*)", ) if file_name is None: return if len(file_name) < 1: return self.load_file(file_name) @QtCore.pyqtSlot() def on_actionQuit_triggered(self): if self.check_save(): QtCore.QCoreApplication.quit() @QtCore.pyqtSlot() def on_actionReport_a_Problem_triggered(self): url = QtCore.QUrl("https://github.com/cmbruns/schmereo/issues") QtGui.QDesktopServices.openUrl(url) @QtCore.pyqtSlot() def on_actionSave_triggered(self): if self.project_file_name is None: return self.clip_box.recenter() self.save_project_file(self.project_file_name) @QtCore.pyqtSlot() def on_actionSave_Images_triggered(self): if not self.image_saver.can_save(): return self.clip_box.recenter() path = "" if self.project_file_name is not None: path = f"{os.path.splitext(self.project_file_name)[0]}.pns" elif self.project_folder is not None: path = self.project_folder file_name, file_type = QtWidgets.QFileDialog.getSaveFileName( parent=self, caption="Save File(s)", directory=path, filter="3D Images (*.pns *.jps)", ) if file_name is None: return if len(file_name) < 1: return bs = self.clip_box.size self.image_saver.eye_size = (int(bs[0]), int(bs[1])) self.image_saver.save_image(file_name, file_type) if self.project_folder is None: self.project_folder = os.path.dirname(file_name) @QtCore.pyqtSlot() def on_actionSave_Project_As_triggered(self) -> bool: path = "" if self.project_file_name is not None: path = os.path.dirname(self.project_file_name) elif self.project_folder is not None: path = self.project_folder file_name, file_type = QtWidgets.QFileDialog.getSaveFileName( parent=self, caption="Save Project", directory=path, filter="Schmereo Projects (*.json);;All Files (*)", ) if file_name is None: return False if len(file_name) < 1: return False return self.save_project_file(file_name) @QtCore.pyqtSlot() def on_actionZoom_In_triggered(self): self.zoom(amount=self.zoom_increment) @QtCore.pyqtSlot() def on_actionZoom_Out_triggered(self): self.zoom(amount=1.0 / self.zoom_increment) @QtCore.pyqtSlot(bool) def on_undoStack_cleanChanged(self, is_clean: bool): self.ui.actionSave.setEnabled(not is_clean) doc_title = "untitled" if self.project_file_name is not None: doc_title = self.project_file_name if not is_clean: doc_title = f"{doc_title}*" self.setWindowFilePath(doc_title) def recenter_clip_box(self): self.clip_box.recenter() self.clip_box.notify() self.camera.notify() def save_project_file(self, file_name) -> bool: with open(file_name, "w") as fh: self.clip_box.recenter() json.dump(self.to_dict(), fh, indent=2) self.recent_files.add_file(file_name) self.setWindowFilePath(file_name) self.project_file_name = file_name self.project_folder = os.path.dirname(file_name) self.undo_stack.setClean() return True return False def to_dict(self): self.clip_box.recenter() # Normalize values before serialization return { "app": { "name": "schmereo", "version": __version__ }, "clip_box": self.clip_box.to_dict(), "left": self.ui.leftImageWidget.to_dict(), "right": self.ui.rightImageWidget.to_dict(), } def from_dict(self, data): self.ui.leftImageWidget.from_dict(data["left"]) self.ui.rightImageWidget.from_dict(data["right"]) if "clip_box" in data: self.clip_box.from_dict(data["clip_box"]) @QtCore.pyqtSlot() def zoom(self, amount: float): # In case the zoom is not linked between the two image widgets... widgets = (self.ui.leftImageWidget, self.ui.rightImageWidget) # store zoom values in case the cameras are all the same zooms = [w.camera.zoom for w in widgets] for idx, w in enumerate(widgets): w.camera.zoom = zooms[idx] * amount for w in widgets: w.camera.notify() # repaint now
class IconEditorGrid(QWidget): """ Class implementing the icon editor grid. @signal canRedoChanged(bool) emitted after the redo status has changed @signal canUndoChanged(bool) emitted after the undo status has changed @signal clipboardImageAvailable(bool) emitted to signal the availability of an image to be pasted @signal colorChanged(QColor) emitted after the drawing color was changed @signal imageChanged(bool) emitted after the image was modified @signal positionChanged(int, int) emitted after the cursor poition was changed @signal previewChanged(QPixmap) emitted to signal a new preview pixmap @signal selectionAvailable(bool) emitted to signal a change of the selection @signal sizeChanged(int, int) emitted after the size has been changed @signal zoomChanged(int) emitted to signal a change of the zoom value """ canRedoChanged = pyqtSignal(bool) canUndoChanged = pyqtSignal(bool) clipboardImageAvailable = pyqtSignal(bool) colorChanged = pyqtSignal(QColor) imageChanged = pyqtSignal(bool) positionChanged = pyqtSignal(int, int) previewChanged = pyqtSignal(QPixmap) selectionAvailable = pyqtSignal(bool) sizeChanged = pyqtSignal(int, int) zoomChanged = pyqtSignal(int) Pencil = 1 Rubber = 2 Line = 3 Rectangle = 4 FilledRectangle = 5 Circle = 6 FilledCircle = 7 Ellipse = 8 FilledEllipse = 9 Fill = 10 ColorPicker = 11 RectangleSelection = 20 CircleSelection = 21 MarkColor = QColor(255, 255, 255, 255) NoMarkColor = QColor(0, 0, 0, 0) ZoomMinimum = 100 ZoomMaximum = 10000 ZoomStep = 100 ZoomDefault = 1200 ZoomPercent = True def __init__(self, parent=None): """ Constructor @param parent reference to the parent widget (QWidget) """ super(IconEditorGrid, self).__init__(parent) self.setAttribute(Qt.WA_StaticContents) self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) self.__curColor = Qt.black self.__zoom = 12 self.__curTool = self.Pencil self.__startPos = QPoint() self.__endPos = QPoint() self.__dirty = False self.__selecting = False self.__selRect = QRect() self.__isPasting = False self.__clipboardSize = QSize() self.__pasteRect = QRect() self.__undoStack = QUndoStack(self) self.__currentUndoCmd = None self.__image = QImage(32, 32, QImage.Format_ARGB32) self.__image.fill(qRgba(0, 0, 0, 0)) self.__markImage = QImage(self.__image) self.__markImage.fill(self.NoMarkColor.rgba()) self.__compositingMode = QPainter.CompositionMode_SourceOver self.__lastPos = (-1, -1) self.__gridEnabled = True self.__selectionAvailable = False self.__initCursors() self.__initUndoTexts() self.setMouseTracking(True) self.__undoStack.canRedoChanged.connect(self.canRedoChanged) self.__undoStack.canUndoChanged.connect(self.canUndoChanged) self.__undoStack.cleanChanged.connect(self.__cleanChanged) self.imageChanged.connect(self.__updatePreviewPixmap) QApplication.clipboard().dataChanged.connect(self.__checkClipboard) self.__checkClipboard() def __initCursors(self): """ Private method to initialize the various cursors. """ self.__normalCursor = QCursor(Qt.ArrowCursor) pix = QPixmap(":colorpicker-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__colorPickerCursor = QCursor(pix, 1, 21) pix = QPixmap(":paintbrush-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__paintCursor = QCursor(pix, 0, 19) pix = QPixmap(":fill-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__fillCursor = QCursor(pix, 3, 20) pix = QPixmap(":aim-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__aimCursor = QCursor(pix, 10, 10) pix = QPixmap(":eraser-cursor.xpm") mask = pix.createHeuristicMask() pix.setMask(mask) self.__rubberCursor = QCursor(pix, 1, 16) def __initUndoTexts(self): """ Private method to initialize texts to be associated with undo commands for the various drawing tools. """ self.__undoTexts = { self.Pencil: self.tr("Set Pixel"), self.Rubber: self.tr("Erase Pixel"), self.Line: self.tr("Draw Line"), self.Rectangle: self.tr("Draw Rectangle"), self.FilledRectangle: self.tr("Draw Filled Rectangle"), self.Circle: self.tr("Draw Circle"), self.FilledCircle: self.tr("Draw Filled Circle"), self.Ellipse: self.tr("Draw Ellipse"), self.FilledEllipse: self.tr("Draw Filled Ellipse"), self.Fill: self.tr("Fill Region"), } def isDirty(self): """ Public method to check the dirty status. @return flag indicating a modified status (boolean) """ return self.__dirty def setDirty(self, dirty, setCleanState=False): """ Public slot to set the dirty flag. @param dirty flag indicating the new modification status (boolean) @param setCleanState flag indicating to set the undo stack to clean (boolean) """ self.__dirty = dirty self.imageChanged.emit(dirty) if not dirty and setCleanState: self.__undoStack.setClean() def sizeHint(self): """ Public method to report the size hint. @return size hint (QSize) """ size = self.__zoom * self.__image.size() if self.__zoom >= 3 and self.__gridEnabled: size += QSize(1, 1) return size def setPenColor(self, newColor): """ Public method to set the drawing color. @param newColor reference to the new color (QColor) """ self.__curColor = QColor(newColor) self.colorChanged.emit(QColor(newColor)) def penColor(self): """ Public method to get the current drawing color. @return current drawing color (QColor) """ return QColor(self.__curColor) def setCompositingMode(self, mode): """ Public method to set the compositing mode. @param mode compositing mode to set (QPainter.CompositionMode) """ self.__compositingMode = mode def compositingMode(self): """ Public method to get the compositing mode. @return compositing mode (QPainter.CompositionMode) """ return self.__compositingMode def setTool(self, tool): """ Public method to set the current drawing tool. @param tool drawing tool to be used (IconEditorGrid.Pencil ... IconEditorGrid.CircleSelection) """ self.__curTool = tool self.__lastPos = (-1, -1) if self.__curTool in [self.RectangleSelection, self.CircleSelection]: self.__selecting = True else: self.__selecting = False if self.__curTool in [self.RectangleSelection, self.CircleSelection, self.Line, self.Rectangle, self.FilledRectangle, self.Circle, self.FilledCircle, self.Ellipse, self.FilledEllipse]: self.setCursor(self.__aimCursor) elif self.__curTool == self.Fill: self.setCursor(self.__fillCursor) elif self.__curTool == self.ColorPicker: self.setCursor(self.__colorPickerCursor) elif self.__curTool == self.Pencil: self.setCursor(self.__paintCursor) elif self.__curTool == self.Rubber: self.setCursor(self.__rubberCursor) else: self.setCursor(self.__normalCursor) def tool(self): """ Public method to get the current drawing tool. @return current drawing tool (IconEditorGrid.Pencil ... IconEditorGrid.CircleSelection) """ return self.__curTool def setIconImage(self, newImage, undoRedo=False, clearUndo=False): """ Public method to set a new icon image. @param newImage reference to the new image (QImage) @keyparam undoRedo flag indicating an undo or redo operation (boolean) @keyparam clearUndo flag indicating to clear the undo stack (boolean) """ if newImage != self.__image: self.__image = newImage.convertToFormat(QImage.Format_ARGB32) self.update() self.updateGeometry() self.resize(self.sizeHint()) self.__markImage = QImage(self.__image) self.__markImage.fill(self.NoMarkColor.rgba()) if undoRedo: self.setDirty(not self.__undoStack.isClean()) else: self.setDirty(False) if clearUndo: self.__undoStack.clear() self.sizeChanged.emit(*self.iconSize()) def iconImage(self): """ Public method to get a copy of the icon image. @return copy of the icon image (QImage) """ return QImage(self.__image) def iconSize(self): """ Public method to get the size of the icon. @return width and height of the image as a tuple (integer, integer) """ return self.__image.width(), self.__image.height() def setZoomFactor(self, newZoom): """ Public method to set the zoom factor in percent. @param newZoom zoom factor (integer >= 100) """ newZoom = max(100, newZoom) # must not be less than 100 if newZoom != self.__zoom: self.__zoom = newZoom // 100 self.update() self.updateGeometry() self.resize(self.sizeHint()) self.zoomChanged.emit(int(self.__zoom * 100)) def zoomFactor(self): """ Public method to get the current zoom factor in percent. @return zoom factor (integer) """ return self.__zoom * 100 def setGridEnabled(self, enable): """ Public method to enable the display of grid lines. @param enable enabled status of the grid lines (boolean) """ if enable != self.__gridEnabled: self.__gridEnabled = enable self.update() def isGridEnabled(self): """ Public method to get the grid lines status. @return enabled status of the grid lines (boolean) """ return self.__gridEnabled def paintEvent(self, evt): """ Protected method called to repaint some of the widget. @param evt reference to the paint event object (QPaintEvent) """ painter = QPainter(self) if self.__zoom >= 3 and self.__gridEnabled: painter.setPen(self.palette().windowText().color()) i = 0 while i <= self.__image.width(): painter.drawLine( self.__zoom * i, 0, self.__zoom * i, self.__zoom * self.__image.height()) i += 1 j = 0 while j <= self.__image.height(): painter.drawLine( 0, self.__zoom * j, self.__zoom * self.__image.width(), self.__zoom * j) j += 1 col = QColor("#aaa") painter.setPen(Qt.DashLine) for i in range(0, self.__image.width()): for j in range(0, self.__image.height()): rect = self.__pixelRect(i, j) if evt.region().intersects(rect): color = QColor.fromRgba(self.__image.pixel(i, j)) painter.fillRect(rect, QBrush(Qt.white)) painter.fillRect(QRect(rect.topLeft(), rect.center()), col) painter.fillRect(QRect(rect.center(), rect.bottomRight()), col) painter.fillRect(rect, QBrush(color)) if self.__isMarked(i, j): painter.drawRect(rect.adjusted(0, 0, -1, -1)) painter.end() def __pixelRect(self, i, j): """ Private method to determine the rectangle for a given pixel coordinate. @param i x-coordinate of the pixel in the image (integer) @param j y-coordinate of the pixel in the image (integer) @return rectangle for the given pixel coordinates (QRect) """ if self.__zoom >= 3 and self.__gridEnabled: return QRect(self.__zoom * i + 1, self.__zoom * j + 1, self.__zoom - 1, self.__zoom - 1) else: return QRect(self.__zoom * i, self.__zoom * j, self.__zoom, self.__zoom) def mousePressEvent(self, evt): """ Protected method to handle mouse button press events. @param evt reference to the mouse event object (QMouseEvent) """ if evt.button() == Qt.LeftButton: if self.__isPasting: self.__isPasting = False self.editPaste(True) self.__markImage.fill(self.NoMarkColor.rgba()) self.update(self.__pasteRect) self.__pasteRect = QRect() return if self.__curTool == self.Pencil: cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) self.__setImagePixel(evt.pos(), True) self.setDirty(True) self.__undoStack.push(cmd) self.__currentUndoCmd = cmd elif self.__curTool == self.Rubber: cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) self.__setImagePixel(evt.pos(), False) self.setDirty(True) self.__undoStack.push(cmd) self.__currentUndoCmd = cmd elif self.__curTool == self.Fill: i, j = self.__imageCoordinates(evt.pos()) col = QColor() col.setRgba(self.__image.pixel(i, j)) cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) self.__drawFlood(i, j, col) self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) elif self.__curTool == self.ColorPicker: i, j = self.__imageCoordinates(evt.pos()) col = QColor() col.setRgba(self.__image.pixel(i, j)) self.setPenColor(col) else: self.__unMark() self.__startPos = evt.pos() self.__endPos = evt.pos() def mouseMoveEvent(self, evt): """ Protected method to handle mouse move events. @param evt reference to the mouse event object (QMouseEvent) """ self.positionChanged.emit(*self.__imageCoordinates(evt.pos())) if self.__isPasting and not (evt.buttons() & Qt.LeftButton): self.__drawPasteRect(evt.pos()) return if evt.buttons() & Qt.LeftButton: if self.__curTool == self.Pencil: self.__setImagePixel(evt.pos(), True) self.setDirty(True) elif self.__curTool == self.Rubber: self.__setImagePixel(evt.pos(), False) self.setDirty(True) elif self.__curTool in [self.Fill, self.ColorPicker]: pass # do nothing else: self.__drawTool(evt.pos(), True) def mouseReleaseEvent(self, evt): """ Protected method to handle mouse button release events. @param evt reference to the mouse event object (QMouseEvent) """ if evt.button() == Qt.LeftButton: if self.__curTool in [self.Pencil, self.Rubber]: if self.__currentUndoCmd: self.__currentUndoCmd.setAfterImage(self.__image) self.__currentUndoCmd = None if self.__curTool not in [self.Pencil, self.Rubber, self.Fill, self.ColorPicker, self.RectangleSelection, self.CircleSelection]: cmd = IconEditCommand(self, self.__undoTexts[self.__curTool], self.__image) if self.__drawTool(evt.pos(), False): self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) self.setDirty(True) def __setImagePixel(self, pos, opaque): """ Private slot to set or erase a pixel. @param pos position of the pixel in the widget (QPoint) @param opaque flag indicating a set operation (boolean) """ i, j = self.__imageCoordinates(pos) if self.__image.rect().contains(i, j) and (i, j) != self.__lastPos: if opaque: painter = QPainter(self.__image) painter.setPen(self.penColor()) painter.setCompositionMode(self.__compositingMode) painter.drawPoint(i, j) else: self.__image.setPixel(i, j, qRgba(0, 0, 0, 0)) self.__lastPos = (i, j) self.update(self.__pixelRect(i, j)) def __imageCoordinates(self, pos): """ Private method to convert from widget to image coordinates. @param pos widget coordinate (QPoint) @return tuple with the image coordinates (tuple of two integers) """ i = pos.x() // self.__zoom j = pos.y() // self.__zoom return i, j def __drawPasteRect(self, pos): """ Private slot to draw a rectangle for signaling a paste operation. @param pos widget position of the paste rectangle (QPoint) """ self.__markImage.fill(self.NoMarkColor.rgba()) if self.__pasteRect.isValid(): self.__updateImageRect( self.__pasteRect.topLeft(), self.__pasteRect.bottomRight() + QPoint(1, 1)) x, y = self.__imageCoordinates(pos) isize = self.__image.size() if x + self.__clipboardSize.width() <= isize.width(): sx = self.__clipboardSize.width() else: sx = isize.width() - x if y + self.__clipboardSize.height() <= isize.height(): sy = self.__clipboardSize.height() else: sy = isize.height() - y self.__pasteRect = QRect(QPoint(x, y), QSize(sx - 1, sy - 1)) painter = QPainter(self.__markImage) painter.setPen(self.MarkColor) painter.drawRect(self.__pasteRect) painter.end() self.__updateImageRect(self.__pasteRect.topLeft(), self.__pasteRect.bottomRight() + QPoint(1, 1)) def __drawTool(self, pos, mark): """ Private method to perform a draw operation depending of the current tool. @param pos widget coordinate to perform the draw operation at (QPoint) @param mark flag indicating a mark operation (boolean) @return flag indicating a successful draw (boolean) """ self.__unMark() if mark: self.__endPos = QPoint(pos) drawColor = self.MarkColor img = self.__markImage else: drawColor = self.penColor() img = self.__image start = QPoint(*self.__imageCoordinates(self.__startPos)) end = QPoint(*self.__imageCoordinates(pos)) painter = QPainter(img) painter.setPen(drawColor) painter.setCompositionMode(self.__compositingMode) if self.__curTool == self.Line: painter.drawLine(start, end) elif self.__curTool in [self.Rectangle, self.FilledRectangle, self.RectangleSelection]: left = min(start.x(), end.x()) top = min(start.y(), end.y()) right = max(start.x(), end.x()) bottom = max(start.y(), end.y()) if self.__curTool == self.RectangleSelection: painter.setBrush(QBrush(drawColor)) if self.__curTool == self.FilledRectangle: for y in range(top, bottom + 1): painter.drawLine(left, y, right, y) else: painter.drawRect(left, top, right - left, bottom - top) if self.__selecting: self.__selRect = QRect( left, top, right - left + 1, bottom - top + 1) self.__selectionAvailable = True self.selectionAvailable.emit(True) elif self.__curTool in [self.Circle, self.FilledCircle, self.CircleSelection]: r = max(abs(start.x() - end.x()), abs(start.y() - end.y())) if self.__curTool in [self.FilledCircle, self.CircleSelection]: painter.setBrush(QBrush(drawColor)) painter.drawEllipse(start, r, r) if self.__selecting: self.__selRect = QRect(start.x() - r, start.y() - r, 2 * r + 1, 2 * r + 1) self.__selectionAvailable = True self.selectionAvailable.emit(True) elif self.__curTool in [self.Ellipse, self.FilledEllipse]: r1 = abs(start.x() - end.x()) r2 = abs(start.y() - end.y()) if r1 == 0 or r2 == 0: return False if self.__curTool == self.FilledEllipse: painter.setBrush(QBrush(drawColor)) painter.drawEllipse(start, r1, r2) painter.end() if self.__curTool in [self.Circle, self.FilledCircle, self.Ellipse, self.FilledEllipse]: self.update() else: self.__updateRect(self.__startPos, pos) return True def __drawFlood(self, i, j, oldColor, doUpdate=True): """ Private method to perform a flood fill operation. @param i x-value in image coordinates (integer) @param j y-value in image coordinates (integer) @param oldColor reference to the color at position i, j (QColor) @param doUpdate flag indicating an update is requested (boolean) (used for speed optimizations) """ if not self.__image.rect().contains(i, j) or \ self.__image.pixel(i, j) != oldColor.rgba() or \ self.__image.pixel(i, j) == self.penColor().rgba(): return self.__image.setPixel(i, j, self.penColor().rgba()) self.__drawFlood(i, j - 1, oldColor, False) self.__drawFlood(i, j + 1, oldColor, False) self.__drawFlood(i - 1, j, oldColor, False) self.__drawFlood(i + 1, j, oldColor, False) if doUpdate: self.update() def __updateRect(self, pos1, pos2): """ Private slot to update parts of the widget. @param pos1 top, left position for the update in widget coordinates (QPoint) @param pos2 bottom, right position for the update in widget coordinates (QPoint) """ self.__updateImageRect(QPoint(*self.__imageCoordinates(pos1)), QPoint(*self.__imageCoordinates(pos2))) def __updateImageRect(self, ipos1, ipos2): """ Private slot to update parts of the widget. @param ipos1 top, left position for the update in image coordinates (QPoint) @param ipos2 bottom, right position for the update in image coordinates (QPoint) """ r1 = self.__pixelRect(ipos1.x(), ipos1.y()) r2 = self.__pixelRect(ipos2.x(), ipos2.y()) left = min(r1.x(), r2.x()) top = min(r1.y(), r2.y()) right = max(r1.x() + r1.width(), r2.x() + r2.width()) bottom = max(r1.y() + r1.height(), r2.y() + r2.height()) self.update(left, top, right - left + 1, bottom - top + 1) def __unMark(self): """ Private slot to remove the mark indicator. """ self.__markImage.fill(self.NoMarkColor.rgba()) if self.__curTool in [self.Circle, self.FilledCircle, self.Ellipse, self.FilledEllipse, self.CircleSelection]: self.update() else: self.__updateRect(self.__startPos, self.__endPos) if self.__selecting: self.__selRect = QRect() self.__selectionAvailable = False self.selectionAvailable.emit(False) def __isMarked(self, i, j): """ Private method to check, if a pixel is marked. @param i x-value in image coordinates (integer) @param j y-value in image coordinates (integer) @return flag indicating a marked pixel (boolean) """ return self.__markImage.pixel(i, j) == self.MarkColor.rgba() def __updatePreviewPixmap(self): """ Private slot to generate and signal an updated preview pixmap. """ p = QPixmap.fromImage(self.__image) self.previewChanged.emit(p) def previewPixmap(self): """ Public method to generate a preview pixmap. @return preview pixmap (QPixmap) """ p = QPixmap.fromImage(self.__image) return p def __checkClipboard(self): """ Private slot to check, if the clipboard contains a valid image, and signal the result. """ ok = self.__clipboardImage()[1] self.__clipboardImageAvailable = ok self.clipboardImageAvailable.emit(ok) def canPaste(self): """ Public slot to check the availability of the paste operation. @return flag indicating availability of paste (boolean) """ return self.__clipboardImageAvailable def __clipboardImage(self): """ Private method to get an image from the clipboard. @return tuple with the image (QImage) and a flag indicating a valid image (boolean) """ img = QApplication.clipboard().image() ok = not img.isNull() if ok: img = img.convertToFormat(QImage.Format_ARGB32) return img, ok def __getSelectionImage(self, cut): """ Private method to get an image from the selection. @param cut flag indicating to cut the selection (boolean) @return image of the selection (QImage) """ if cut: cmd = IconEditCommand(self, self.tr("Cut Selection"), self.__image) img = QImage(self.__selRect.size(), QImage.Format_ARGB32) img.fill(qRgba(0, 0, 0, 0)) for i in range(0, self.__selRect.width()): for j in range(0, self.__selRect.height()): if self.__image.rect().contains(self.__selRect.x() + i, self.__selRect.y() + j): if self.__isMarked( self.__selRect.x() + i, self.__selRect.y() + j): img.setPixel(i, j, self.__image.pixel( self.__selRect.x() + i, self.__selRect.y() + j)) if cut: self.__image.setPixel(self.__selRect.x() + i, self.__selRect.y() + j, qRgba(0, 0, 0, 0)) if cut: self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) self.__unMark() if cut: self.update(self.__selRect) return img def editCopy(self): """ Public slot to copy the selection. """ if self.__selRect.isValid(): img = self.__getSelectionImage(False) QApplication.clipboard().setImage(img) def editCut(self): """ Public slot to cut the selection. """ if self.__selRect.isValid(): img = self.__getSelectionImage(True) QApplication.clipboard().setImage(img) @pyqtSlot() def editPaste(self, pasting=False): """ Public slot to paste an image from the clipboard. @param pasting flag indicating part two of the paste operation (boolean) """ img, ok = self.__clipboardImage() if ok: if img.width() > self.__image.width() or \ img.height() > self.__image.height(): res = E5MessageBox.yesNo( self, self.tr("Paste"), self.tr( """<p>The clipboard image is larger than the""" """ current image.<br/>Paste as new image?</p>""")) if res: self.editPasteAsNew() return elif not pasting: self.__isPasting = True self.__clipboardSize = img.size() else: cmd = IconEditCommand(self, self.tr("Paste Clipboard"), self.__image) self.__markImage.fill(self.NoMarkColor.rgba()) painter = QPainter(self.__image) painter.setPen(self.penColor()) painter.setCompositionMode(self.__compositingMode) painter.drawImage( self.__pasteRect.x(), self.__pasteRect.y(), img, 0, 0, self.__pasteRect.width() + 1, self.__pasteRect.height() + 1) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) self.__updateImageRect( self.__pasteRect.topLeft(), self.__pasteRect.bottomRight() + QPoint(1, 1)) else: E5MessageBox.warning( self, self.tr("Pasting Image"), self.tr("""Invalid image data in clipboard.""")) def editPasteAsNew(self): """ Public slot to paste the clipboard as a new image. """ img, ok = self.__clipboardImage() if ok: cmd = IconEditCommand( self, self.tr("Paste Clipboard as New Image"), self.__image) self.setIconImage(img) self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editSelectAll(self): """ Public slot to select the complete image. """ self.__unMark() self.__startPos = QPoint(0, 0) self.__endPos = QPoint(self.rect().bottomRight()) self.__markImage.fill(self.MarkColor.rgba()) self.__selRect = self.__image.rect() self.__selectionAvailable = True self.selectionAvailable.emit(True) self.update() def editClear(self): """ Public slot to clear the image. """ self.__unMark() cmd = IconEditCommand(self, self.tr("Clear Image"), self.__image) self.__image.fill(qRgba(0, 0, 0, 0)) self.update() self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editResize(self): """ Public slot to resize the image. """ from .IconSizeDialog import IconSizeDialog dlg = IconSizeDialog(self.__image.width(), self.__image.height()) res = dlg.exec_() if res == QDialog.Accepted: newWidth, newHeight = dlg.getData() if newWidth != self.__image.width() or \ newHeight != self.__image.height(): cmd = IconEditCommand(self, self.tr("Resize Image"), self.__image) img = self.__image.scaled( newWidth, newHeight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) self.setIconImage(img) self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editNew(self): """ Public slot to generate a new, empty image. """ from .IconSizeDialog import IconSizeDialog dlg = IconSizeDialog(self.__image.width(), self.__image.height()) res = dlg.exec_() if res == QDialog.Accepted: width, height = dlg.getData() img = QImage(width, height, QImage.Format_ARGB32) img.fill(qRgba(0, 0, 0, 0)) self.setIconImage(img) def grayScale(self): """ Public slot to convert the image to gray preserving transparency. """ cmd = IconEditCommand(self, self.tr("Convert to Grayscale"), self.__image) for x in range(self.__image.width()): for y in range(self.__image.height()): col = self.__image.pixel(x, y) if col != qRgba(0, 0, 0, 0): gray = qGray(col) self.__image.setPixel( x, y, qRgba(gray, gray, gray, qAlpha(col))) self.update() self.setDirty(True) self.__undoStack.push(cmd) cmd.setAfterImage(self.__image) def editUndo(self): """ Public slot to perform an undo operation. """ if self.__undoStack.canUndo(): self.__undoStack.undo() def editRedo(self): """ Public slot to perform a redo operation. """ if self.__undoStack.canRedo(): self.__undoStack.redo() def canUndo(self): """ Public method to return the undo status. @return flag indicating the availability of undo (boolean) """ return self.__undoStack.canUndo() def canRedo(self): """ Public method to return the redo status. @return flag indicating the availability of redo (boolean) """ return self.__undoStack.canRedo() def __cleanChanged(self, clean): """ Private slot to handle the undo stack clean state change. @param clean flag indicating the clean state (boolean) """ self.setDirty(not clean) def shutdown(self): """ Public slot to perform some shutdown actions. """ self.__undoStack.canRedoChanged.disconnect(self.canRedoChanged) self.__undoStack.canUndoChanged.disconnect(self.canUndoChanged) self.__undoStack.cleanChanged.disconnect(self.__cleanChanged) def isSelectionAvailable(self): """ Public method to check the availability of a selection. @return flag indicating the availability of a selection (boolean) """ return self.__selectionAvailable
class AbstractPage(QObject): sigChanged = pyqtSignal() def __init__(self, parent: MasterDocument): super().__init__() self._objs = set() self._parent = parent self.undoStack = QUndoStack() self._name = "untitled" @property def name(self): return self._name @name.setter def name(self, n): self._name = n self.sigChanged.emit() @property def parentDoc(self): return self._parent def isModified(self): return not self.undoStack.isClean() def doCommand(self, cmd): cmd.doc = self self.undoStack.push(cmd) self.sigChanged.emit() def objects(self, objType=None, exclude=None): if not objType and not exclude: return set(self._objs) if exclude is None: exclude = () if objType: return {obj for obj in self._objs if type(obj) is objType and not (type(obj) in exclude)} else: return {obj for obj in self._objs if not (type(obj) in exclude)} def hasObject(self, obj): return obj in self._objs def findObjsInRect(self, rect: QRect, objType=None): return {obj for obj in self.objects(objType) if rect.intersects(obj.bbox())} def findObjsNear(self, pt: QPoint, dist=1, objType=None): hitRect = QRect(pt.x()-dist/2, pt.y()-dist/2, dist, dist) return {obj for obj in self.findObjsInRect(hitRect, objType) if obj.testHit(pt, dist)} def addObj(self, obj): self._objs.add(obj) self.sigChanged.emit() def removeObj(self, obj): self._objs.remove(obj) self.sigChanged.emit() @pyqtSlot() def undo(self): self.undoStack.undo() self.sigChanged.emit() @pyqtSlot() def redo(self): self.undoStack.redo() self.sigChanged.emit() def fromXml(self, pageNode): raise NotImplementedError() def toXml(self, parentNode): raise NotImplementedError()