comparison 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 (2019-09-16)
parents
children 37e3f2781fdf
comparison
equal deleted inserted replaced
103:2d1ffeafb003 104:dd2a309eefa9
1 from PyQt5 import QtCore as QC, QtGui as QG, QtWidgets as QW
2
3 from colours import NES_PALETTE, TILE_PALETTES, is_dark, widget_icon_path
4
5 from nes import NES_ROM, Tile
6
7 BUTTON_SIZE = 32
8
9
10 class GridHolder(object):
11 def __init__(self, things, numrows, numcols, fillvalue=None):
12 self.numrows = numrows
13 self.numcols = numcols
14 self.fillvalue = fillvalue
15 if things is None:
16 self.things = []
17 for i in range(self.numrows):
18 self.things.extend([fillvalue]*self.numcols)
19 else:
20 self.things = things
21
22 def __getitem__(self, idx):
23 i, j = idx
24 try:
25 return self.things[i*self.numcols + j]
26 except IndexError:
27 return self.fillvalue
28
29 def __setitem__(self, idx, value):
30 i, j = idx
31 self.things[i*self.numcols + j] = value
32
33 def __repr__(self):
34 return f"GridHolder(numrows={self.numrows}, numcols={self.numcols}, fillvalue={self.fillvalue})"
35
36 def __iter__(self):
37 for thing in self.things:
38 yield thing
39
40 def __len__(self):
41 return len(self.things)
42
43 def resize(self, numrows, numcols):
44 self.numrows = numrows
45 self.numcols = numcols
46
47
48 class TileGrid(QW.QWidget):
49
50 DEFAULT_SCALE_FACTOR = 5
51
52 def __init__(self, numrows, numcols, scalefactor, spacing):
53 super().__init__()
54
55 self.numcols = numcols
56 self.numrows = numrows
57 self.scalefactor = scalefactor
58 self.spacing = spacing
59
60 self.setStyleSheet(self.CSS)
61
62 def paintEvent(self, event):
63 painter = QG.QPainter(self)
64
65 # I don't really know what this block of code means, but it's
66 # what I have to do in order to get this widget to obey CSS
67 # styles.
68 opt = QW.QStyleOption()
69 opt.initFrom(self)
70 style = self.style()
71 style.drawPrimitive(QW.QStyle.PE_Widget, opt, painter, self)
72
73 # Let's be a little economical and only repaint the visible region
74 x_start, y_start, x_end, y_end = event.rect().getCoords()
75 i_start, j_start = self.coordsToTileIndices(x_start, y_start)
76 i_end, j_end = self.coordsToTileIndices(x_end, y_end)
77
78 for i in range(i_start, i_end+1):
79 for j in range(j_start, j_end+1):
80 tile = self.tiles[i, j]
81 if tile:
82 rect = self.getRectangle(i, j)
83 flipx, flipy = self.flips[i, j]
84 painter.drawPixmap(
85 rect,
86 tile.pixmap.transformed(QG.QTransform().scale(flipx, flipy))
87 )
88
89 def getRectangle(self, i, j):
90 return QC.QRect(
91 self.spacing + j*self.tilesize,
92 self.spacing + i*self.tilesize,
93 self.tilesize - self.spacing,
94 self.tilesize - self.spacing
95 )
96
97 def coordsToTileIndices(self, x, y):
98 j, i = (
99 (x - self.spacing)//self.tilesize,
100 (y - self.spacing)//self.tilesize,
101 )
102 return (i, j)
103
104 def resize(self):
105 self.tilesize = self.spacing + self.scalefactor*8
106 self.setFixedSize(QC.QSize(
107 self.tilesize*self.numcols + self.spacing,
108 self.tilesize*self.numrows + self.spacing,
109 ))
110 self.tiles.resize(self.numrows, self.numcols)
111 self.update()
112
113 def zoomOut(self):
114 self.scalefactor -= 1
115 if self.scalefactor < 1:
116 self.scalefactor = 1
117 return
118
119 self.resize()
120
121 def zoomIn(self):
122 self.scalefactor += 1
123 if self.scalefactor > 10:
124 self.scalefactor = 10
125 return
126
127 self.resize()
128
129 def zoomOriginal(self):
130 self.scalefactor = self.DEFAULT_SCALE_FACTOR
131
132 self.resize()
133
134 def computeNumRows(self):
135 pass
136
137 def increaseCols(self):
138 maxcols = len(self.tiles)
139 self.numcols += 1
140 if self.numcols > maxcols:
141 self.numcols = maxcols
142 self.computeNumRows()
143 self.resize()
144
145 def decreaseCols(self):
146 self.numcols -= 1
147 if self.numcols < 1:
148 self.numcols = 1
149 self.computeNumRows()
150 self.resize()
151
152
153 class ROMCanvas(TileGrid):
154 CSS = """
155 background-image: url(img/checkerboard.png);
156 background-repeat: repeat-xy;
157 """
158
159 def __init__(self, rom, tile_picker):
160 self.rom = rom
161 self.tile_picker = tile_picker
162 tiles = rom.tiles
163
164 super().__init__(
165 numcols=16,
166 numrows=len(tiles)//16,
167 spacing=2,
168 scalefactor=5
169 )
170
171 self.tiles = GridHolder(tiles, self.numrows, self.numcols)
172 self.flips = GridHolder(None, self.numrows, self.numcols, fillvalue=(1, 1))
173
174 self.picked_tile = None
175
176 self.resize()
177
178 def computeNumRows(self):
179 self.numrows = len(self.tiles)//self.numcols
180
181 def mousePressEvent(self, event):
182 i, j = self.coordsToTileIndices(event.x(), event.y())
183 tile = self.tiles[i, j]
184
185 self.tile_picker.picked_tile = tile
186
187
188 class TilePicker(TileGrid):
189 CSS = """
190 background-image: url(img/grey-checkerboard.png);
191 background-repeat: repeat-xy;
192 """
193
194 def __init__(self, rom_canvas=None):
195 super().__init__(
196 numcols=50,
197 numrows=30,
198 spacing=0,
199 scalefactor=5,
200 )
201
202 self.tiles = GridHolder(None, self.numrows, self.numcols)
203 self.flips = GridHolder(None, self.numcols, self.numcols, fillvalue=(1, 1))
204 self.picked_tile = None
205
206 self.resize()
207
208 def mousePressEvent(self, event):
209 if not self.picked_tile:
210 return
211
212 i, j = self.coordsToTileIndices(event.x(), event.y())
213
214 if event.button() == QC.Qt.LeftButton:
215 self.tiles[i, j] = self.picked_tile
216 self.flips[i, j] = (1, 1)
217 elif event.button() == QC.Qt.RightButton:
218 self.tiles[i, j] = None
219 elif event.button() == QC.Qt.MidButton:
220 flipx, flipy = self.flips[i, j]
221 if flipx == 1:
222 if flipy == 1:
223 self.flips[i, j] = (1, -1)
224 else:
225 self.flips[i, j] = (-1, 1)
226 else:
227 if flipy == 1:
228 self.flips[i, j] = (-1, -1)
229 else:
230 self.flips[i, j] = (1, 1)
231
232 self.update()
233
234 def mouseMoveEvent(self, event):
235 i, j = self.coordsToTileIndices(event.x(), event.y())
236
237
238 class ColourPicker(QW.QDialog):
239
240 def __init__(self):
241 super().__init__()
242 self.setWindowTitle("Pick a colour")
243
244 layout = QW.QGridLayout()
245 layout.setSpacing(0)
246
247 for i in range(4):
248 for j in range(16):
249 colour_idx = i*16 + j
250 button = ColourButton(colour_idx)
251 button.pressed.connect(lambda c=colour_idx: self.colour_picked(c))
252 layout.addWidget(button, i, j)
253 colours = QW.QWidget()
254 layout.setSizeConstraint(QW.QLayout.SetFixedSize)
255 colours.setLayout(layout)
256
257 vlayout = QW.QVBoxLayout()
258 vlayout.setSpacing(0)
259 vlayout.setSizeConstraint(QW.QLayout.SetFixedSize)
260 vlayout.addWidget(colours)
261
262 transparent_button = QW.QPushButton("Transparent")
263
264 transparent_button.pressed.connect(lambda: self.colour_picked(None))
265 vlayout.addWidget(transparent_button)
266 self.setLayout(vlayout)
267
268 def colour_picked(self, colour_idx):
269 self.picked_idx = colour_idx
270 self.accept()
271
272
273 class ColourButton(QW.QPushButton):
274 def __init__(self, colour_idx):
275 super().__init__()
276 self.setFixedSize(QC.QSize(BUTTON_SIZE, BUTTON_SIZE))
277 self.colour_idx = colour_idx
278 self.set_colour(colour_idx)
279 self.setToolTip("Change colour")
280
281 def set_colour(self, colour_idx):
282 self.colour_idx = colour_idx
283
284 if colour_idx is None:
285 # Enable transparency
286 self.setText("")
287 self.setStyleSheet("")
288 return
289
290 self.setText(f"{colour_idx:0{2}X}")
291
292 bgcolour = NES_PALETTE[colour_idx]
293 qt_colour = QG.QColor(bgcolour)
294
295 textcolour = 'white' if is_dark(qt_colour) else 'black'
296 bordercolour = "#444444" if qt_colour.rgb() == 0 else qt_colour.darker().name()
297 self.setStyleSheet(
298 f'''
299 QPushButton {{
300 color: {textcolour};
301 background: {bgcolour};
302 border: 2px outset {bordercolour};
303 border-radius: 4px;
304 }}
305 QPushButton:pressed {{
306 border-style: inset;
307 }}'''
308 )
309
310
311 class ScrollAreaWithVerticalBar(QW.QScrollArea):
312 def sizeHint(self):
313 hint = super().sizeHint()
314 bar_width = self.verticalScrollBar().sizeHint().width()
315 return QC.QSize(hint.width() + bar_width, hint.height())
316
317
318 class PaletteButton(ColourButton):
319
320 def __init__(self, button_idx, colour_idx):
321 super().__init__(colour_idx)
322 self.button_idx = button_idx
323
324
325 class TilePaletteButtons(QW.QWidget):
326 def __init__(self, palette, callback=None):
327 super().__init__()
328
329 self.layout = QW.QHBoxLayout()
330 self.layout.setSpacing(0)
331 margins = self.layout.contentsMargins()
332 margins.setLeft(0)
333 self.layout.setContentsMargins(margins)
334 self.layout.setSizeConstraint(QW.QLayout.SetFixedSize)
335
336 for button_idx, color_idx in enumerate(palette):
337 button = PaletteButton(button_idx, color_idx)
338 if callback:
339 button.pressed.connect(lambda idx=button_idx: callback(idx))
340 self.layout.addWidget(button)
341 self.setLayout(self.layout)
342
343
344 class ROMDockable(QW.QDockWidget):
345
346 def __init__(self, filename, filetype, tile_picker):
347 super().__init__(filename.split("/")[-1])
348
349 rom = NES_ROM(filename)
350
351 self.rom_canvas = ROMCanvas(rom, tile_picker)
352 self.colour_picker = ColourPicker()
353
354 scroll = ScrollAreaWithVerticalBar(self)
355 scroll.setWidget(self.rom_canvas)
356
357 self.toolbar = QW.QHBoxLayout()
358
359 palette_layout = QW.QHBoxLayout()
360 palette_layout.setSpacing(0)
361 palette_layout.setSizeConstraint(QW.QLayout.SetFixedSize)
362
363 self.rom_palette_buttons = TilePaletteButtons(
364 Tile.default_palette,
365 callback=self.pick_palette_colour
366 )
367 palette_layout.addWidget(self.rom_palette_buttons)
368
369 self.rom_palette = QW.QWidget()
370 self.rom_palette.setLayout(palette_layout)
371
372 self.toolbar.addWidget(self.rom_palette)
373
374 for icon, tooltip, function in [
375 ("zoom-in", "Zoom in", "zoomIn"),
376 ("zoom-out", "Zoom out", "zoomOut"),
377 ("zoom-original", "Restore zoom", "zoomOriginal"),
378 ("increaseCols", "Increase number of columns", "increaseCols"),
379 ("decreaseCols", "Decrease number of columns", "decreaseCols"),
380 ]:
381 button = QW.QPushButton("")
382 iconpath = widget_icon_path(button)
383 button.setIcon(QG.QIcon(f"img/{iconpath}/{icon}.svg"))
384 button.setFixedSize(QC.QSize(32, 32))
385 button.setIconSize(QC.QSize(24, 24))
386 button.setToolTip(tooltip)
387 button.pressed.connect(getattr(self.rom_canvas, function))
388 self.toolbar.addWidget(button)
389
390 self.toolbar.addStretch()
391
392 layout = QW.QVBoxLayout()
393 layout.addWidget(scroll)
394 toolbar_widget = QW.QWidget()
395 toolbar_widget.setLayout(self.toolbar)
396 layout.addWidget(toolbar_widget)
397 rom_canvas_widget = QW.QWidget()
398 rom_canvas_widget.setLayout(layout)
399
400 self.setWidget(rom_canvas_widget)
401
402 iconpath = widget_icon_path(self)
403 self.setStyleSheet(
404 f"""
405 QDockWidget
406 {{
407 titlebar-close-icon: url(img/{iconpath}/widget-close.svg);
408 titlebar-normal-icon: url(img/{iconpath}/widget-undock.svg);
409 }}
410 """
411 )
412
413 def pick_palette_colour(self, palette_idx):
414 if self.colour_picker.exec_():
415 new_colour_idx = self.colour_picker.picked_idx
416 self.rom_palette_buttons.layout.itemAt(palette_idx).widget().set_colour(new_colour_idx)
417 for tile in self.rom_canvas.tiles:
418 palette = tile.palette
419 palette[palette_idx] = new_colour_idx
420 tile.set_palette(palette)
421 self.rom_canvas.update()
422 self.rom_canvas.tile_picker.update()
423
424 def change_palette(self, name):
425 self.rom_canvas.palette = TILE_PALETTES[name]
426 for tile in self.rom_canvas.tiles:
427 tile.set_palette(self.rom_canvas.palette.copy())
428
429 for button_idx, colour_idx in enumerate(self.rom_canvas.palette):
430 self.rom_palette_buttons.layout.itemAt(button_idx).widget().set_colour(colour_idx)
431 self.rom_canvas.update()
432 self.rom_canvas.tile_picker.update()