view widgets.py @ 125:1117edef8b2b draft default tip

NES_ROM: new `from_json` and `to_json` methods To allow saving/loading the ROM status.
author Jordi Gutiérrez Hermoso <jordigh@octave.org>
date Wed, 02 Oct 2019 09:15:51 -0400 (2019-10-02)
parents 4b1e034dc9fb
children
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 exceptions import ROMOpeningError
from nes import NES_ROM, Tile

BUTTON_SIZE = 32
ICON_SIZE = 24


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

    def byteForward(self):
        self.rom.byte_forward()
        self.update()
        self.tile_picker.update()

    def byteBackward(self):
        self.rom.byte_backward()
        self.update()
        self.tile_picker.update()


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 PaletteDropdownItemDelegate(QW.QStyledItemDelegate):
    skip = 10

    def __init__(self):
        super().__init__()
        self.fontHeight = QW.QPushButton().fontMetrics().height()

    def paint(self, painter, option, index):
        name = index.data()
        pixmap = TilePaletteButtons(TILE_PALETTES[name]).grab()
        style = option.widget.style()
        style.drawControl(QW.QStyle.CE_ItemViewItem, option, painter)
        textRect = style.subElementRect(QW.QStyle.SE_ItemViewItemText, option)
        drawRect = QC.QRect(
            textRect.x(), self.fontHeight + textRect.y() + self.skip/2,
            textRect.width(), textRect.height() - self.skip - self.fontHeight
        )
        painter.drawText(textRect, 0, name)
        painter.drawPixmap(drawRect, pixmap)

    def sizeHint(self, option, index):
        return QC.QSize(4*BUTTON_SIZE,
                        BUTTON_SIZE + self.skip + self.fontHeight)


class PaletteComboBox(QW.QComboBox):

    def __init__(self):
        super().__init__()
        self.setItemDelegate(PaletteDropdownItemDelegate())

        for name, tile_palette in TILE_PALETTES.items():
            self.addItem(name)

    def showPopup(self):
        super().showPopup()
        popup = self.findChild(QW.QFrame)
        popup.move(popup.x() + self.width(), popup.y())
        popup.setFixedWidth(4*BUTTON_SIZE)

    def sizeHint(self):
        return QC.QSize(23, BUTTON_SIZE)

    def minimumSizeHint(self):
        return self.sizeHint()


class ROMDockable(QW.QDockWidget):

    def __init__(self, filename, filetype, tile_picker):
        rom = NES_ROM(filename)

        title = filename.split("/")[-1]
        title += " (CHR)" if rom.CHR else " (PRG)"

        super().__init__(title)

        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)

        combo = PaletteComboBox()
        combo.currentIndexChanged[str].connect(self.change_palette)
        palette_layout.addWidget(combo)

        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)

        button_groups = [
            [
                ("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"),
            ],
        ]
        if not rom.CHR:
            button_groups.append([
                ("go-up", "Move all tiles one byte forward", "byteForward" if not rom.CHR else None),
                ("go-down", "Move all tiles one byte backard", "byteBackward" if not rom.CHR else None),
            ])

        for group in button_groups:
            for icon, tooltip, function in group:
                button = QW.QPushButton("")
                iconpath = widget_icon_path(button)
                button.setIcon(QG.QIcon(f"img/{iconpath}/{icon}.svg"))
                button.setFixedSize(QC.QSize(BUTTON_SIZE, BUTTON_SIZE))
                button.setIconSize(QC.QSize(ICON_SIZE, ICON_SIZE))
                button.setToolTip(tooltip)
                button.pressed.connect(getattr(self.rom_canvas, function))
                self.toolbar.addWidget(button)
            self.toolbar.addSpacing(BUTTON_SIZE)
        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()


class MainWindow(QW.QMainWindow):
    def __init__(self, app):
        super().__init__()

        self.resize(QC.QSize(1200, 800))
        self.rom_dockables = []

        bar = self.menuBar()
        file = bar.addMenu("&File")
        open_action = file.addAction("&Open")
        quit_action = file.addAction("&Quit")
        open_action.triggered.connect(self.open_rom)
        quit_action.triggered.connect(app.quit)

        self.tile_picker = TilePicker()

        tile_picker_scroll = QW.QScrollArea(self)
        tile_picker_scroll.setWidget(self.tile_picker)

        self.setCentralWidget(tile_picker_scroll)

    def open_rom(self):
        filename, filetype = QW.QFileDialog.getOpenFileName(
            self,
            "Open ROM", "", "NES Files (*.nes *.NES *.zip *.ZIP)"
        )
        if not filename:
            return

        try:
            new_rom_dockable = ROMDockable(filename, filetype, self.tile_picker)
        except ROMOpeningError as exc:
            QW.QMessageBox.critical(self, "Tilerswift message", str(exc))
            return

        try:
            last_docked = self.rom_dockables[-1]
            self.tabifyDockWidget(last_docked, new_rom_dockable)
        except IndexError:
            self.addDockWidget(QC.Qt.LeftDockWidgetArea, new_rom_dockable)

        new_rom_dockable.show()
        new_rom_dockable.raise_()
        self.rom_dockables.append(new_rom_dockable)