Mercurial > hg > tilerswift
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)