view widgets.py @ 104:dd2a309eefa9

refactor: move widgets into their own module This allows multiple ROM canvases to be opened at once, and each one has its own palette, which can change the palette in the tile picker.
author Jordi Gutiérrez Hermoso <jordigh@octave.org>
date Sun, 15 Sep 2019 22:03:03 -0400
parents
children 37e3f2781fdf
line wrap: on
line source

from PyQt5 import QtCore as QC, QtGui as QG, QtWidgets as QW

from colours import NES_PALETTE, TILE_PALETTES, is_dark, widget_icon_path

from nes import NES_ROM, Tile

BUTTON_SIZE = 32


class GridHolder(object):
    def __init__(self, things, numrows, numcols, fillvalue=None):
        self.numrows = numrows
        self.numcols = numcols
        self.fillvalue = fillvalue
        if things is None:
            self.things = []
            for i in range(self.numrows):
                self.things.extend([fillvalue]*self.numcols)
        else:
            self.things = things

    def __getitem__(self, idx):
        i, j = idx
        try:
            return self.things[i*self.numcols + j]
        except IndexError:
            return self.fillvalue

    def __setitem__(self, idx, value):
        i, j = idx
        self.things[i*self.numcols + j] = value

    def __repr__(self):
        return f"GridHolder(numrows={self.numrows}, numcols={self.numcols}, fillvalue={self.fillvalue})"

    def __iter__(self):
        for thing in self.things:
            yield thing

    def __len__(self):
        return len(self.things)

    def resize(self, numrows, numcols):
        self.numrows = numrows
        self.numcols = numcols


class TileGrid(QW.QWidget):

    DEFAULT_SCALE_FACTOR = 5

    def __init__(self, numrows, numcols, scalefactor, spacing):
        super().__init__()

        self.numcols = numcols
        self.numrows = numrows
        self.scalefactor = scalefactor
        self.spacing = spacing

        self.setStyleSheet(self.CSS)

    def paintEvent(self, event):
        painter = QG.QPainter(self)

        # I don't really know what this block of code means, but it's
        # what I have to do in order to get this widget to obey CSS
        # styles.
        opt = QW.QStyleOption()
        opt.initFrom(self)
        style = self.style()
        style.drawPrimitive(QW.QStyle.PE_Widget, opt, painter, self)

        # Let's be a little economical and only repaint the visible region
        x_start, y_start, x_end, y_end = event.rect().getCoords()
        i_start, j_start = self.coordsToTileIndices(x_start, y_start)
        i_end, j_end = self.coordsToTileIndices(x_end, y_end)

        for i in range(i_start, i_end+1):
            for j in range(j_start, j_end+1):
                tile = self.tiles[i, j]
                if tile:
                    rect = self.getRectangle(i, j)
                    flipx, flipy = self.flips[i, j]
                    painter.drawPixmap(
                        rect,
                        tile.pixmap.transformed(QG.QTransform().scale(flipx, flipy))
                    )

    def getRectangle(self, i, j):
        return QC.QRect(
            self.spacing + j*self.tilesize,
            self.spacing + i*self.tilesize,
            self.tilesize - self.spacing,
            self.tilesize - self.spacing
        )

    def coordsToTileIndices(self, x, y):
        j, i = (
            (x - self.spacing)//self.tilesize,
            (y - self.spacing)//self.tilesize,
        )
        return (i, j)

    def resize(self):
        self.tilesize = self.spacing + self.scalefactor*8
        self.setFixedSize(QC.QSize(
            self.tilesize*self.numcols + self.spacing,
            self.tilesize*self.numrows + self.spacing,
        ))
        self.tiles.resize(self.numrows, self.numcols)
        self.update()

    def zoomOut(self):
        self.scalefactor -= 1
        if self.scalefactor < 1:
            self.scalefactor = 1
            return

        self.resize()

    def zoomIn(self):
        self.scalefactor += 1
        if self.scalefactor > 10:
            self.scalefactor = 10
            return

        self.resize()

    def zoomOriginal(self):
        self.scalefactor = self.DEFAULT_SCALE_FACTOR

        self.resize()

    def computeNumRows(self):
        pass

    def increaseCols(self):
        maxcols = len(self.tiles)
        self.numcols += 1
        if self.numcols > maxcols:
            self.numcols = maxcols
        self.computeNumRows()
        self.resize()

    def decreaseCols(self):
        self.numcols -= 1
        if self.numcols < 1:
            self.numcols = 1
        self.computeNumRows()
        self.resize()


class ROMCanvas(TileGrid):
    CSS = """
    background-image: url(img/checkerboard.png);
    background-repeat: repeat-xy;
    """

    def __init__(self, rom, tile_picker):
        self.rom = rom
        self.tile_picker = tile_picker
        tiles = rom.tiles

        super().__init__(
            numcols=16,
            numrows=len(tiles)//16,
            spacing=2,
            scalefactor=5
        )

        self.tiles = GridHolder(tiles, self.numrows, self.numcols)
        self.flips = GridHolder(None, self.numrows, self.numcols, fillvalue=(1, 1))

        self.picked_tile = None

        self.resize()

    def computeNumRows(self):
        self.numrows = len(self.tiles)//self.numcols

    def mousePressEvent(self, event):
        i, j = self.coordsToTileIndices(event.x(), event.y())
        tile = self.tiles[i, j]

        self.tile_picker.picked_tile = tile


class TilePicker(TileGrid):
    CSS = """
    background-image: url(img/grey-checkerboard.png);
    background-repeat: repeat-xy;
    """

    def __init__(self, rom_canvas=None):
        super().__init__(
            numcols=50,
            numrows=30,
            spacing=0,
            scalefactor=5,
        )

        self.tiles = GridHolder(None, self.numrows, self.numcols)
        self.flips = GridHolder(None, self.numcols, self.numcols, fillvalue=(1, 1))
        self.picked_tile = None

        self.resize()

    def mousePressEvent(self, event):
        if not self.picked_tile:
            return

        i, j = self.coordsToTileIndices(event.x(), event.y())

        if event.button() == QC.Qt.LeftButton:
            self.tiles[i, j] = self.picked_tile
            self.flips[i, j] = (1, 1)
        elif event.button() == QC.Qt.RightButton:
            self.tiles[i, j] = None
        elif event.button() == QC.Qt.MidButton:
            flipx, flipy = self.flips[i, j]
            if flipx == 1:
                if flipy == 1:
                    self.flips[i, j] = (1, -1)
                else:
                    self.flips[i, j] = (-1, 1)
            else:
                if flipy == 1:
                    self.flips[i, j] = (-1, -1)
                else:
                    self.flips[i, j] = (1, 1)

        self.update()

    def mouseMoveEvent(self, event):
        i, j = self.coordsToTileIndices(event.x(), event.y())


class ColourPicker(QW.QDialog):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Pick a colour")

        layout = QW.QGridLayout()
        layout.setSpacing(0)

        for i in range(4):
            for j in range(16):
                colour_idx = i*16 + j
                button = ColourButton(colour_idx)
                button.pressed.connect(lambda c=colour_idx: self.colour_picked(c))
                layout.addWidget(button, i, j)
        colours = QW.QWidget()
        layout.setSizeConstraint(QW.QLayout.SetFixedSize)
        colours.setLayout(layout)

        vlayout = QW.QVBoxLayout()
        vlayout.setSpacing(0)
        vlayout.setSizeConstraint(QW.QLayout.SetFixedSize)
        vlayout.addWidget(colours)

        transparent_button = QW.QPushButton("Transparent")

        transparent_button.pressed.connect(lambda: self.colour_picked(None))
        vlayout.addWidget(transparent_button)
        self.setLayout(vlayout)

    def colour_picked(self, colour_idx):
        self.picked_idx = colour_idx
        self.accept()


class ColourButton(QW.QPushButton):
    def __init__(self, colour_idx):
        super().__init__()
        self.setFixedSize(QC.QSize(BUTTON_SIZE, BUTTON_SIZE))
        self.colour_idx = colour_idx
        self.set_colour(colour_idx)
        self.setToolTip("Change colour")

    def set_colour(self, colour_idx):
        self.colour_idx = colour_idx

        if colour_idx is None:
            # Enable transparency
            self.setText("")
            self.setStyleSheet("")
            return

        self.setText(f"{colour_idx:0{2}X}")

        bgcolour = NES_PALETTE[colour_idx]
        qt_colour = QG.QColor(bgcolour)

        textcolour = 'white' if is_dark(qt_colour) else 'black'
        bordercolour = "#444444" if qt_colour.rgb() == 0 else qt_colour.darker().name()
        self.setStyleSheet(
            f'''
            QPushButton {{
              color: {textcolour};
              background: {bgcolour};
              border: 2px outset {bordercolour};
              border-radius: 4px;
            }}
            QPushButton:pressed {{
              border-style: inset;
            }}'''
        )


class ScrollAreaWithVerticalBar(QW.QScrollArea):
    def sizeHint(self):
        hint = super().sizeHint()
        bar_width = self.verticalScrollBar().sizeHint().width()
        return QC.QSize(hint.width() + bar_width, hint.height())


class PaletteButton(ColourButton):

    def __init__(self, button_idx, colour_idx):
        super().__init__(colour_idx)
        self.button_idx = button_idx


class TilePaletteButtons(QW.QWidget):
    def __init__(self, palette, callback=None):
        super().__init__()

        self.layout = QW.QHBoxLayout()
        self.layout.setSpacing(0)
        margins = self.layout.contentsMargins()
        margins.setLeft(0)
        self.layout.setContentsMargins(margins)
        self.layout.setSizeConstraint(QW.QLayout.SetFixedSize)

        for button_idx, color_idx in enumerate(palette):
            button = PaletteButton(button_idx, color_idx)
            if callback:
                button.pressed.connect(lambda idx=button_idx: callback(idx))
            self.layout.addWidget(button)
        self.setLayout(self.layout)


class ROMDockable(QW.QDockWidget):

    def __init__(self, filename, filetype, tile_picker):
        super().__init__(filename.split("/")[-1])

        rom = NES_ROM(filename)

        self.rom_canvas = ROMCanvas(rom, tile_picker)
        self.colour_picker = ColourPicker()

        scroll = ScrollAreaWithVerticalBar(self)
        scroll.setWidget(self.rom_canvas)

        self.toolbar = QW.QHBoxLayout()

        palette_layout = QW.QHBoxLayout()
        palette_layout.setSpacing(0)
        palette_layout.setSizeConstraint(QW.QLayout.SetFixedSize)

        self.rom_palette_buttons = TilePaletteButtons(
            Tile.default_palette,
            callback=self.pick_palette_colour
        )
        palette_layout.addWidget(self.rom_palette_buttons)

        self.rom_palette = QW.QWidget()
        self.rom_palette.setLayout(palette_layout)

        self.toolbar.addWidget(self.rom_palette)

        for icon, tooltip, function in [
            ("zoom-in", "Zoom in", "zoomIn"),
            ("zoom-out", "Zoom out", "zoomOut"),
            ("zoom-original", "Restore zoom", "zoomOriginal"),
            ("increaseCols", "Increase number of columns", "increaseCols"),
            ("decreaseCols", "Decrease number of columns", "decreaseCols"),
        ]:
            button = QW.QPushButton("")
            iconpath = widget_icon_path(button)
            button.setIcon(QG.QIcon(f"img/{iconpath}/{icon}.svg"))
            button.setFixedSize(QC.QSize(32, 32))
            button.setIconSize(QC.QSize(24, 24))
            button.setToolTip(tooltip)
            button.pressed.connect(getattr(self.rom_canvas, function))
            self.toolbar.addWidget(button)

        self.toolbar.addStretch()

        layout = QW.QVBoxLayout()
        layout.addWidget(scroll)
        toolbar_widget = QW.QWidget()
        toolbar_widget.setLayout(self.toolbar)
        layout.addWidget(toolbar_widget)
        rom_canvas_widget = QW.QWidget()
        rom_canvas_widget.setLayout(layout)

        self.setWidget(rom_canvas_widget)

        iconpath = widget_icon_path(self)
        self.setStyleSheet(
           f"""
            QDockWidget
            {{
              titlebar-close-icon: url(img/{iconpath}/widget-close.svg);
              titlebar-normal-icon: url(img/{iconpath}/widget-undock.svg);
            }}
            """
        )

    def pick_palette_colour(self, palette_idx):
        if self.colour_picker.exec_():
            new_colour_idx = self.colour_picker.picked_idx
            self.rom_palette_buttons.layout.itemAt(palette_idx).widget().set_colour(new_colour_idx)
            for tile in self.rom_canvas.tiles:
                palette = tile.palette
                palette[palette_idx] = new_colour_idx
                tile.set_palette(palette)
            self.rom_canvas.update()
            self.rom_canvas.tile_picker.update()

    def change_palette(self, name):
        self.rom_canvas.palette = TILE_PALETTES[name]
        for tile in self.rom_canvas.tiles:
            tile.set_palette(self.rom_canvas.palette.copy())

        for button_idx, colour_idx in enumerate(self.rom_canvas.palette):
            self.rom_palette_buttons.layout.itemAt(button_idx).widget().set_colour(colour_idx)
        self.rom_canvas.update()
        self.rom_canvas.tile_picker.update()