Source code for pixelpalette

import math
from PyQt5.QtCore import Qt, QSize, QLineF, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QIcon, QPixmap, QImage, QPainter, QPen
from PyQt5.QtWidgets import (
    QWidget,
    QLabel,
    QFrame,
    QDockWidget,
    QGridLayout,
    QHBoxLayout,
    QVBoxLayout,
    QToolButton,
    QScrollArea,
    QSizePolicy,
    QToolTip,
)
from source import Source
import resources  # noqa: F401


[docs]class Overlay(QLabel): """Pixel palette overlay used to draw gridlines and selection box :param parent: Parent widget, defaults to None :type parent: QWidget, optional """ tiles_selected = pyqtSignal(int, int, int) def __init__(self, parent=None): super().__init__(parent) self.height = 0 self.selected = (0, 0, 0) self.setMouseTracking(True) self.orig_x = self.orig_y = 0 self.orig_index = 0 self.last_x = self.last_y = 0
[docs] def setDims(self, height): """Set height in sprites/tiles of pixel palette :param height: Number of rows in pixel palette :type height: int """ self.height = height self.setFixedSize(16 * 25, self.height * 25)
[docs] def selectSubjects(self, root, width, height): """Select rectangular region of sprites/tiles :param root: Index of root selected element :type root: int :param width: Width of selection from root :type width: int :param height: Height of selection from root :type height: int """ self.selected = (root, width, height) self.update()
[docs] def paintEvent(self, event): """Paint event for the overlay :param event: QPaintEvent event :type event: QPaintEvent """ super().paintEvent(event) painter = QPainter(self) pen = QPen(Qt.black) pen.setWidth(1) painter.setPen(pen) painter.setBrush(Qt.NoBrush) painter.drawRect(0, 0, 16 * 25 - 1, self.height * 25 - 1) lines = [] for longitude in range(16): line = QLineF(longitude * 25, 0, longitude * 25, self.height * 25) lines.append(line) for latitude in range(self.height): line = QLineF(0, latitude * 25, 16 * 25, latitude * 25) lines.append(line) painter.drawLines(lines) s_root, s_width, s_height = self.selected x = s_root % 16 y = math.floor(s_root / 16) pen.setColor(Qt.red) pen.setWidth(3) pen.setJoinStyle(Qt.MiterJoin) painter.setPen(pen) painter.drawRect(x * 25, y * 25, s_width * 25, s_height * 25)
[docs] def getCoords(self, event): """Get bounded coordinates of a mouse event :param event: Source event :type event: QMouseEvent :return: Bounded horizontal and vertical coordinate of mouse event :rtype: tuple(int, int) """ x = min(max(0, math.floor(event.localPos().x() / 25)), 16) y = min(max(0, math.floor(event.localPos().y() / 25)), self.height) return x, y
[docs] def clamp(self, value, lower=0, upper=16): """Clamp a coordinate to bounded dimensions :param value: Value to clamp :type value: int :param lower: Lower bound of clamp, defaults to 0 :type lower: int, optional :param upper: Upper bound of clamp, defaults to 16 :type upper: int, optional :return: Clamped coordinate :rtype: int """ return min(max(lower, value), upper)
[docs] def mousePressEvent(self, event): """Event to handle mouse clicks on overlay :param event: Source event :type event: QMouseEvent """ if event.buttons() == Qt.LeftButton: self.orig_x, self.orig_y = ( math.floor(event.pos().x() / 25), math.floor(event.pos().y() / 25), ) self.orig_index = self.orig_x + self.orig_y * 16 self.last_x, self.last_y = (self.orig_x, self.orig_y) if event.modifiers() != Qt.ControlModifier: self.tiles_selected.emit(self.orig_index, -1, -1)
[docs] def mouseMoveEvent(self, event): """Event to handle mouse movement after clicking on overlay :param event: Source event :type event: QMouseEvent """ lclick = event.buttons() == Qt.LeftButton ctrl = event.modifiers() == Qt.ControlModifier if ctrl and lclick: x, y = ( self.clamp(math.ceil(event.pos().x() / 25)), self.clamp(math.ceil(event.pos().y() / 25), upper=self.height), ) if (x, y) != (self.last_x, self.last_y): width = x - self.orig_x if x > self.orig_x else -1 height = y - self.orig_y if y > self.orig_y else -1 self.tiles_selected.emit(self.orig_index, width, height) self.last_x, self.last_y = ( self.clamp(math.floor(event.pos().x() / 25)), self.clamp(math.floor(self.pos().y() / 25), upper=self.height), ) else: x, y = self.getCoords(event) QToolTip.showText(event.globalPos(), hex(x + y * 16))
[docs]class Tile(QLabel): def __init__(self, index, parent=None): """Tile representing rendered version of single tile/sprite :param index: Index of element in centralized GameData data :type index: intr :param parent: Parent widget, defaults to None :type parent: QWidget, optional """ super().__init__(parent) self.setFixedSize(25, 25) self.color_palette = [0] * 16 self.data = [[0] * 8] * 8 self.index = index
[docs] def setData(self, data): """Set pixel data of tile :param data: Pixel data list :type data: list(int) """ self.data = bytes([pix for sub in data for pix in sub])
[docs] def setColors(self, palette): """Set color palette of tile :param palette: List of colors representing color palette :type palette: list(QColor) """ self.color_palette = palette
[docs] def update(self): """Update the tile QImage """ image = QImage(self.data, 8, 8, QImage.Format_Indexed8).scaled(25, 25) image.setColorTable([color.rgba() for color in self.color_palette]) self.setPixmap(QPixmap.fromImage(image))
[docs]class PixelPalette(QFrame): """Palette of sprite/tiles to be used to select canvas editing area :param source: Source subject of the palette, either tile or sprite :type source: Source :param parent: Parent widget, defaults to None :type parent: QWidget, optional """ subject_selected = pyqtSignal(int, int, int) def __init__(self, source, parent=None): super().__init__(parent) self.source = source self.data = None self.contents = [] self.overlay = None self.selected = None self.grid = QGridLayout() self.grid.setSpacing(0) self.grid.setContentsMargins(0, 0, 0, 0) self.grid.setAlignment(Qt.AlignTop) self.setLayout(self.grid) self.enabled = False self.loc_cache = {} self.selected = 0 self.top_left = (0, 0) self.bottom_right = (0, 0) self.select_width = 1 self.select_height = 1
[docs] def setup(self, data): """Sets up the data source for the palette and generates the palette :param data: Data source of palette :type data: GameData """ self.data = data self.selected = 0 self.current_palette = list(self.data.getColPals(self.source))[0] self.genPalette() self.data.pix_batch_updated.connect(self.updateSubjects)
[docs] @pyqtSlot() def genPalette(self): """Generates the palette based on the data source """ for i in reversed(range(self.grid.count())): self.grid.itemAt(i).widget().setParent(None) self.contents.clear() row = col = index = 0 palettes = enumerate(self.data.getPixelPalettes(self.source)) for index, element in palettes: tile = Tile(index, self) tile.setColors(self.current_palette) tile.setData(element) tile.update() self.contents.append(tile) self.grid.addWidget(self.contents[index], row, col) col = col + 1 if col < 15 else 0 row = row + 1 if col == 0 else row self.overlay = Overlay() self.overlay.tiles_selected.connect(self.selectSubjects) self.overlay.setDims(math.floor(self.contents.__len__() / 16)) self.grid.addWidget(self.overlay, 0, 0, -1, -1) self.genLocCache(math.floor(self.contents.__len__() / 16)) self.selectSubjects(index=self.selected)
pyqtSlot(int, int, int)
[docs] def selectSubjects(self, index=None, width=-1, height=-1): """Selects a rectangular region of tiles in the palette. None and negative index/width/height values result in internal values being used. :param index: Root index of selection, defaults to None :type index: int, optional :param width: Width of selection from index, defaults to -1 :type width: int, optional :param height: Height of selection from index, defaults to -1 :type height: int, optional """ self.select_width = width if width > 0 else self.select_width self.select_height = height if height > 0 else self.select_height self.selected = index if index is not None else self.selected data = self.data.getPixelPalettes(self.source) num_rows = math.floor(data.__len__() / 16) initial_row = math.floor(self.selected / 16) width = math.floor((self.selected + self.select_width - 1) / 16) if width > initial_row: self.selected = initial_row * 16 + 16 - self.select_width if initial_row + self.select_height > num_rows: temp_index = self.selected - 16 * ( initial_row + self.select_height - num_rows ) if temp_index < 0: self.select_height -= 1 else: self.selected = temp_index self.top_left = self.genCoords(self.selected) self.bottom_right = self.genCoords( self.selected + 16 * self.select_height + self.select_width ) self.overlay.selectSubjects( self.selected, self.select_width, self.select_height ) self.subject_selected.emit( self.selected, self.select_width, self.select_height )
pyqtSlot(Source, set)
[docs] def updateSubjects(self, source, subjects): """Updates the palette's tiles with data from the centralized GameData :param source: Source subject of update, either sprite or tile :type source: Source :param subjects: A set of subject indexes which need to be updated :type subjects: set(int) """ if source is not self.source: return lowest = len(self.contents) - 1 for subject in subjects: tile = self.contents[subject] tile.setData(self.data.getElement(subject, self.source)) tile.update() lowest = subject if tile.index < lowest else lowest if not self.inSelection(lowest): self.selectSubjects(lowest) else: self.subject_selected.emit( self.selected, self.select_width, self.select_height )
pyqtSlot(str)
[docs] def setColorPalette(self, palette): """Set the color palette to be used by the sprites/tiles :param palette: Name of the color palette :type palette: str """ if self.data is not None: self.current_palette = self.data.getColPal(palette, self.source) for subject in self.contents: subject.setColors(self.current_palette) subject.update()
[docs] def inSelection(self, index): """Determine if an index is inside the rectangular selection area :param index: Target index :type index: int :return: Whether the target index is inside the selection :rtype: bool """ x1, y1 = self.top_left x2, y2 = self.bottom_right x, y = self.loc_cache[index] return x1 <= x <= x2 - 1 and y1 <= y <= y2 - 1
[docs] @pyqtSlot(int) def genLocCache(self, height): """Generates a cache of 2D locations for indexes :param height: Height of the pixel palette in tiles/sprites :type height: int """ self.loc_cache.clear() for loc in range(height * 16): self.loc_cache[loc] = self.genCoords(loc)
[docs] def genCoords(self, index): """Generates the 2D coordinates for a given element index :param index: Index of the element :type index: int :return: (x,y) coordinates of the element :rtype: tuple(int, int) """ return (index % 16, math.floor(index / 16))
[docs]class Contents(QWidget): """Visual contents of the pixel palette :param source: Subject source of the contents, either sprite or tile :type source: Source :param palette: Palette to render in contents :type palette: PixelPalette :param parent: Parent widget, defaults to None :type parent: QWidget, optional """ height_changed = pyqtSignal() def __init__(self, source, palette, parent=None): super().__init__(parent) self.source = source self.palette = palette self.height = 0 row_ctrl_layout = QHBoxLayout() self.add_row = QToolButton(self) self.add_row.clicked.connect(self.addPalRow) self.add_row.setToolTip("Add new row") self.add_row.setEnabled(False) add_icon = QIcon() add_icon.addPixmap(QPixmap(":/icons/add_row.png")) self.add_row.setIcon(add_icon) self.add_row.setIconSize(QSize(24, 24)) self.rem_row = QToolButton(self) self.rem_row.clicked.connect(self.remPalRow) self.rem_row.setToolTip("Remove last row") self.rem_row.setEnabled(False) remove_icon = QIcon() remove_icon.addPixmap(QPixmap(":/icons/remove_row.png")) self.rem_row.setIconSize(QSize(24, 24)) self.rem_row.setIcon(remove_icon) row_ctrl_layout.addWidget(self.add_row) row_ctrl_layout.addWidget(self.rem_row) row_ctrl_layout.addStretch() scroll_area = QScrollArea(self) self.setFixedWidth( scroll_area.verticalScrollBar().sizeHint().width() + 420 ) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) scroll_area.setWidgetResizable(True) scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) scroll_area.setWidget(self.palette) main_layout = QVBoxLayout() main_layout.addLayout(row_ctrl_layout) main_layout.addWidget(scroll_area) self.setLayout(main_layout)
[docs] def setup(self, data): """Sets up the data source for the contents and set dimensions :param data: Data source of contents :type data: GameData """ self.data = data self.data.row_count_updated.connect(self.palRowCntChanged) self.height = math.floor( self.data.getPixelPalettes(self.source).__len__() / 16 ) if self.height <= 1: self.rem_row.setEnabled(False) self.add_row.setEnabled(True) self.rem_row.setEnabled(True)
[docs] @pyqtSlot() def addPalRow(self): """Appends a row to the content palette """ self.data.addPixRow(self.source)
[docs] @pyqtSlot() def remPalRow(self): """Removes the last rom from the content palette """ if self.height > 1: self.data.remPixRow(self.source)
[docs] @pyqtSlot(Source, int) def palRowCntChanged(self, source, num_rows): """Limits changes to the palette row count by enabling/disable UI elements :param source: [description] :type source: [type] :param num_rows: [description] :type num_rows: [type] """ if source is not self.source: return self.height = num_rows self.height_changed.emit() if self.height <= 1: self.rem_row.setEnabled(False) else: self.rem_row.setEnabled(True)
[docs]class PixelPaletteDock(QDockWidget): """Dock containing the pixel palette :param source: Subject source of the dock, either sprite or tile :type source: Source :param parent: Parent widget, defaults to None :type parent: QWidget, optional """ palette_updated = pyqtSignal(str) def __init__(self, source, parent=None): title = "Sprite " if source == Source.SPRITE else "Tile " super().__init__(title + "Palettes", parent) self.setFloating(False) self.setFeatures( QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetMovable ) self.pixel_palette = PixelPalette(source) self.contents = Contents(source, self.pixel_palette) self.palette_updated.connect(self.pixel_palette.setColorPalette) self.setWidget(self.contents)
[docs] def setup(self, data): """Sets up the data source for the dock :param data: Data source of dock :type data: GameData """ self.pixel_palette.setup(data) self.contents.setup(data) self.contents.height_changed.connect(self.pixel_palette.genPalette)
[docs] def closeEvent(self, event): """Handles the close event of the dock by disable it :param event: Close event :type event: QCloseEvent """ event.ignore()