changeset 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 2d1ffeafb003
children 37e3f2781fdf
files exceptions.py tilerswift widgets.py
diffstat 3 files changed, 452 insertions(+), 386 deletions(-) [+]
line wrap: on
line diff
new file mode 100644
--- /dev/null
+++ b/exceptions.py
@@ -0,0 +1,2 @@
+class ROMOpeningError(Exception):
+    pass
--- a/tilerswift
+++ b/tilerswift
@@ -1,320 +1,10 @@
 #!/usr/bin/python3
 import sys
 
-from PyQt5 import QtCore as QC, QtGui as QG, QtWidgets as QW
-
-from colours import (
-    NES_PALETTE, TILE_PALETTES, is_dark, palette_to_qt,
-    widget_icon_path
-)
-
-from nes import NES_ROM, Tile
-
-
-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 = QC.QRect(
-                        self.spacing + j*self.tilesize,
-                        self.spacing + i*self.tilesize,
-                        self.tilesize - self.spacing,
-                        self.tilesize - self.spacing
-                    )
-                    flipx, flipy = self.flips[i, j]
-                    painter.drawPixmap(
-                        rect,
-                        tile.pixmap.transformed(QG.QTransform().scale(flipx, flipy))
-                    )
-
-    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;
-    """
+from PyQt5 import QtCore as QC, QtWidgets as QW
 
-    def __init__(self, rom):
-        self.rom = rom
-        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.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.rom_canvas = rom_canvas
-        self.tiles = GridHolder(None, self.numrows, self.numcols)
-        self.flips = GridHolder(None, self.numcols, self.numcols, fillvalue=(1, 1))
-
-        self.resize()
-
-    def mousePressEvent(self, event):
-        i, j = self.coordsToTileIndices(event.x(), event.y())
-
-        if event.button() == QC.Qt.LeftButton:
-            self.tiles[i, j] = self.rom_canvas.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()
-
-
-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(32, 32))
-        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
+from exceptions import ROMOpeningError
+from widgets import TilePicker, ROMDockable
 
 
 class MainWindow(QW.QMainWindow):
@@ -322,6 +12,8 @@
         super().__init__()
 
         self.resize(QC.QSize(1200, 800))
+        self.rom_dockables = []
+
         bar = self.menuBar()
         file = bar.addMenu("&File")
         open_action = file.addAction("&Open")
@@ -330,7 +22,6 @@
         quit_action.triggered.connect(app.quit)
 
         self.tile_picker = TilePicker()
-        self.colour_picker = ColourPicker()
 
         tile_picker_scroll = QW.QScrollArea(self)
         tile_picker_scroll.setWidget(self.tile_picker)
@@ -338,83 +29,24 @@
         self.setCentralWidget(tile_picker_scroll)
 
     def open_rom(self):
-        filename = QW.QFileDialog.getOpenFileName(
+        filename, filetype = QW.QFileDialog.getOpenFileName(
             self,
             "Open ROM", "", "NES Files (*.nes)"
         )
-        rom = NES_ROM(filename[0])
-
-        self.grid_widget = ROMCanvas(rom)
-
-        scroll = ScrollAreaWithVerticalBar(self)
-        scroll.setWidget(self.grid_widget)
-
-        self.toolbar = QW.QHBoxLayout()
-
-        palette_layout = QW.QHBoxLayout()
-        palette_layout.setSpacing(0)
-
-        for button_idx, color_idx in enumerate(Tile.default_palette):
-            button = PaletteButton(button_idx, color_idx)
-            button.pressed.connect(lambda idx=button_idx: self.pick_palette_colour(idx))
-            palette_layout.addWidget(button)
-        palette_layout.setSizeConstraint(QW.QLayout.SetFixedSize)
-        self.palette = QW.QWidget()
-        self.palette.setLayout(palette_layout)
-        self.toolbar.addWidget(self.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.grid_widget, function))
-            self.toolbar.addWidget(button)
+        try:
+            new_rom_dockable = ROMDockable(filename, filetype, self.tile_picker)
+        except ROMOpeningError:
+            pass
 
-        self.toolbar.addStretch()
-
-        left = QW.QVBoxLayout()
-        left.addWidget(scroll)
-        romWidget = QW.QWidget()
-        romWidget.setLayout(left)
-        paletteWidget = QW.QWidget()
-        paletteWidget.setLayout(self.toolbar)
-        left.addWidget(paletteWidget)
-
-        left_dock = QW.QDockWidget(filename[0].split("/")[-1], self)
-        left_dock.setWidget(romWidget)
+        try:
+            last_docked = self.rom_dockables[-1]
+            self.tabifyDockWidget(last_docked, new_rom_dockable)
+        except IndexError:
+            self.addDockWidget(QC.Qt.LeftDockWidgetArea, new_rom_dockable)
 
-        iconpath = widget_icon_path(left_dock)
-        left_dock.setStyleSheet(
-           f"""
-            QDockWidget
-            {{
-              titlebar-close-icon: url(img/{iconpath}/widget-close.svg);
-              titlebar-normal-icon: url(img/{iconpath}/widget-undock.svg);
-            }}
-            """
-        )
-
-        self.addDockWidget(QC.Qt.LeftDockWidgetArea, left_dock)
-        self.tile_picker.rom_canvas = self.grid_widget
-
-    def pick_palette_colour(self, palette_idx):
-        if self.colour_picker.exec_():
-            new_colour_idx = self.colour_picker.picked_idx
-            self.palette.layout().itemAt(palette_idx).widget().set_colour(new_colour_idx)
-            for tile in self.grid_widget.tiles:
-                palette = tile.palette
-                palette[palette_idx] = new_colour_idx
-                tile.set_palette(palette)
-            self.grid_widget.update()
+        new_rom_dockable.show()
+        new_rom_dockable.raise_()
+        self.rom_dockables.append(new_rom_dockable)
 
 
 app = QW.QApplication(sys.argv)
new file mode 100644
--- /dev/null
+++ b/widgets.py
@@ -0,0 +1,432 @@
+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()