aboutsummaryrefslogtreecommitdiff
path: root/src/avp/components/life.py
diff options
context:
space:
mode:
authorAeliton G. Silva2026-01-12 22:39:55 -0300
committerAeliton G. Silva2026-01-13 04:22:25 -0300
commitf975144f25d34f97329b2d4e52891061573cea08 (patch)
tree226fe223b31af6f217b1dd413629ab2cf26964d4 /src/avp/components/life.py
parentb8703752ffc7768b0275897b3c2a869ff41504e5 (diff)
Use pyproject.toml + uv_build
This replaces setup.py by a modern pyproject.toml using uv_build backend. Dependencies are being also managed by uv, so to install dependencies and run the project one can execute: ``` uv sync uv run pytest # optional python -m avp ``` To build the both source and binary (wheel) distribution package run: ``` uv build ``` Uv can be installed with `pip install uv`. The directory structure has been changed to reflect best practices. - src/* -> src/avp/ - src/tests -> ../tests
Diffstat (limited to 'src/avp/components/life.py')
-rw-r--r--src/avp/components/life.py520
1 files changed, 520 insertions, 0 deletions
diff --git a/src/avp/components/life.py b/src/avp/components/life.py
new file mode 100644
index 0000000..5b719d1
--- /dev/null
+++ b/src/avp/components/life.py
@@ -0,0 +1,520 @@
+from PyQt6 import QtGui, QtCore, QtWidgets
+from PyQt6.QtGui import QUndoCommand
+from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter
+import os
+import math
+import logging
+
+
+from ..component import Component
+from ..toolkit.frame import BlankFrame, scale
+
+
+log = logging.getLogger("AVP.Component.Life")
+
+
+class Component(Component):
+ name = "Conway's Game of Life"
+ version = "1.0.0"
+
+ def widget(self, *args):
+ super().widget(*args)
+ self.scale = 32
+ self.updateGridSize()
+ # The initial grid: a "Queen Bee Shuttle"
+ # https://conwaylife.com/wiki/Queen_bee_shuttle
+ self.startingGrid = set(
+ [
+ (3, 7),
+ (3, 8),
+ (4, 7),
+ (4, 8),
+ (8, 7),
+ (9, 6),
+ (9, 8),
+ (10, 5),
+ (10, 9),
+ (11, 6),
+ (11, 7),
+ (11, 8),
+ (12, 4),
+ (12, 5),
+ (12, 9),
+ (12, 10),
+ (23, 6),
+ (23, 7),
+ (24, 6),
+ (24, 7),
+ ]
+ )
+
+ # Amount of 'bleed' (off-canvas coordinates) on each side of the grid
+ self.bleedSize = 40
+
+ self.page.pushButton_pickImage.clicked.connect(self.pickImage)
+ self.trackWidgets(
+ {
+ "tickRate": self.page.spinBox_tickRate,
+ "scale": self.page.spinBox_scale,
+ "color": self.page.lineEdit_color,
+ "shapeType": self.page.comboBox_shapeType,
+ "shadow": self.page.checkBox_shadow,
+ "customImg": self.page.checkBox_customImg,
+ "showGrid": self.page.checkBox_showGrid,
+ "image": self.page.lineEdit_image,
+ },
+ colorWidgets={
+ "color": self.page.pushButton_color,
+ },
+ )
+ self.shiftButtons = (
+ self.page.toolButton_up,
+ self.page.toolButton_down,
+ self.page.toolButton_left,
+ self.page.toolButton_right,
+ )
+
+ def shiftFunc(i):
+ def shift():
+ self.shiftGrid(i)
+
+ return shift
+
+ shiftFuncs = [shiftFunc(i) for i in range(len(self.shiftButtons))]
+ for i, widget in enumerate(self.shiftButtons):
+ widget.clicked.connect(shiftFuncs[i])
+ self.page.spinBox_scale.setValue(self.scale)
+ self.page.spinBox_scale.valueChanged.connect(self.updateGridSize)
+
+ def pickImage(self):
+ imgDir = self.settings.value("componentDir", os.path.expanduser("~"))
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
+ self.page,
+ "Choose Image",
+ imgDir,
+ "Image Files (%s)" % " ".join(self.core.imageFormats),
+ )
+ if filename:
+ self.settings.setValue("componentDir", os.path.dirname(filename))
+ self.mergeUndo = False
+ self.page.lineEdit_image.setText(filename)
+ self.mergeUndo = True
+
+ def shiftGrid(self, d):
+ action = ShiftGrid(self, d)
+ self.parent.undoStack.push(action)
+
+ def update(self):
+ self.updateGridSize()
+ if self.page.checkBox_customImg.isChecked():
+ self.page.label_color.setVisible(False)
+ self.page.lineEdit_color.setVisible(False)
+ self.page.pushButton_color.setVisible(False)
+ self.page.label_shape.setVisible(False)
+ self.page.comboBox_shapeType.setVisible(False)
+ self.page.label_image.setVisible(True)
+ self.page.lineEdit_image.setVisible(True)
+ self.page.pushButton_pickImage.setVisible(True)
+ else:
+ self.page.label_color.setVisible(True)
+ self.page.lineEdit_color.setVisible(True)
+ self.page.pushButton_color.setVisible(True)
+ self.page.label_shape.setVisible(True)
+ self.page.comboBox_shapeType.setVisible(True)
+ self.page.label_image.setVisible(False)
+ self.page.lineEdit_image.setVisible(False)
+ self.page.pushButton_pickImage.setVisible(False)
+ enabled = len(self.startingGrid) > 0
+ for widget in self.shiftButtons:
+ widget.setEnabled(enabled)
+
+ def previewClickEvent(self, pos, size, button):
+ pos = (
+ math.ceil((pos[0] / size[0]) * self.gridWidth) - 1,
+ math.ceil((pos[1] / size[1]) * self.gridHeight) - 1,
+ )
+ action = ClickGrid(self, pos, button)
+ self.parent.undoStack.push(action)
+
+ def updateGridSize(self):
+ w, h = self.core.resolutions[-1].split("x")
+ self.gridWidth = int(int(w) / self.scale)
+ self.gridHeight = int(int(h) / self.scale)
+ self.pxWidth = math.ceil(self.width / self.gridWidth)
+ self.pxHeight = math.ceil(self.height / self.gridHeight)
+
+ def previewRender(self):
+ return self.drawGrid(self.startingGrid)
+
+ def preFrameRender(self, *args, **kwargs):
+ super().preFrameRender(*args, **kwargs)
+ self.tickGrids = {0: self.startingGrid}
+
+ def properties(self):
+ if self.customImg and (not self.image or not os.path.exists(self.image)):
+ return ["error"]
+ return []
+
+ def error(self):
+ return "No image selected to represent life."
+
+ def frameRender(self, frameNo):
+ tick = math.floor(frameNo / self.tickRate)
+
+ # Compute grid evolution on this frame if it hasn't been computed yet
+ if tick not in self.tickGrids:
+ self.tickGrids[tick] = self.gridForTick(tick)
+ grid = self.tickGrids[tick]
+
+ # Delete old evolution data which we shouldn't need anymore
+ if tick - 60 in self.tickGrids:
+ del self.tickGrids[tick - 60]
+ return self.drawGrid(grid)
+
+ def drawGrid(self, grid):
+ frame = BlankFrame(self.width, self.height)
+
+ def drawCustomImg():
+ try:
+ img = Image.open(self.image)
+ except Exception:
+ return
+ img = img.resize((self.pxWidth, self.pxHeight), Image.Resampling.LANCZOS)
+ frame.paste(img, box=(drawPtX, drawPtY))
+
+ def drawShape():
+ drawer = ImageDraw.Draw(frame)
+ rect = (
+ (drawPtX, drawPtY),
+ (drawPtX + self.pxWidth, drawPtY + self.pxHeight),
+ )
+ shape = self.page.comboBox_shapeType.currentText().lower()
+
+ # Rectangle
+ if shape == "rectangle":
+ drawer.rectangle(rect, fill=self.color)
+
+ # Elliptical
+ elif shape == "elliptical":
+ drawer.ellipse(rect, fill=self.color)
+
+ tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int)
+ smallerShape = (
+ (
+ drawPtX + tenthX + int(tenthX / 4),
+ drawPtY + tenthY + int(tenthY / 2),
+ ),
+ (
+ drawPtX + self.pxWidth - tenthX - int(tenthX / 4),
+ drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)),
+ ),
+ )
+ outlineShape = (
+ (drawPtX + int(tenthX / 4), drawPtY + int(tenthY / 2)),
+ (
+ drawPtX + self.pxWidth - int(tenthX / 4),
+ drawPtY + self.pxHeight - int(tenthY / 2),
+ ),
+ )
+ # Circle
+ if shape == "circle":
+ drawer.ellipse(outlineShape, fill=self.color)
+ drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
+
+ # Lilypad
+ elif shape == "lilypad":
+ drawer.pieslice(smallerShape, 290, 250, fill=self.color)
+
+ # Pie
+ elif shape == "pie":
+ drawer.pieslice(outlineShape, 35, 320, fill=self.color)
+
+ hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline
+ tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline
+ qX, qY = scale(20, self.pxWidth, self.pxHeight, int) # quarterline
+
+ # Path
+ if shape == "path":
+ drawer.ellipse(rect, fill=self.color)
+ rects = {
+ direction: False
+ for direction in (
+ "up",
+ "down",
+ "left",
+ "right",
+ )
+ }
+ for cell in self.nearbyCoords(x, y):
+ if cell not in grid:
+ continue
+ if cell[0] == x:
+ if cell[1] < y:
+ rects["up"] = True
+ if cell[1] > y:
+ rects["down"] = True
+ if cell[1] == y:
+ if cell[0] < x:
+ rects["left"] = True
+ if cell[0] > x:
+ rects["right"] = True
+
+ for direction, rect in rects.items():
+ if rect:
+ if direction == "up":
+ sect = (
+ (drawPtX, drawPtY),
+ (drawPtX + self.pxWidth, drawPtY + hY),
+ )
+ elif direction == "down":
+ sect = (
+ (drawPtX, drawPtY + hY),
+ (
+ drawPtX + self.pxWidth,
+ drawPtY + self.pxHeight,
+ ),
+ )
+ elif direction == "left":
+ sect = (
+ (drawPtX, drawPtY),
+ (drawPtX + hX, drawPtY + self.pxHeight),
+ )
+ elif direction == "right":
+ sect = (
+ (drawPtX + hX, drawPtY),
+ (
+ drawPtX + self.pxWidth,
+ drawPtY + self.pxHeight,
+ ),
+ )
+ drawer.rectangle(sect, fill=self.color)
+
+ # Duck
+ elif shape == "duck":
+ duckHead = (
+ (drawPtX + qX, drawPtY + qY),
+ (drawPtX + int(qX * 3), drawPtY + int(tY * 2)),
+ )
+ duckBeak = (
+ (drawPtX + hX, drawPtY + qY),
+ (drawPtX + self.pxWidth + qX, drawPtY + int(qY * 3)),
+ )
+ duckWing = ((drawPtX, drawPtY + hY), rect[1])
+ duckBody = (
+ (drawPtX + int(qX / 4), drawPtY + int(qY * 3)),
+ (drawPtX + int(tX * 2), drawPtY + self.pxHeight),
+ )
+ drawer.ellipse(duckBody, fill=self.color)
+ drawer.ellipse(duckHead, fill=self.color)
+ drawer.pieslice(duckWing, 130, 200, fill=self.color)
+ drawer.pieslice(duckBeak, 145, 200, fill=self.color)
+
+ # Peace
+ elif shape == "peace":
+ line = (
+ (
+ drawPtX + hX - int(tenthX / 2),
+ drawPtY + int(tenthY / 2),
+ ),
+ (
+ drawPtX + hX + int(tenthX / 2),
+ drawPtY + self.pxHeight - int(tenthY / 2),
+ ),
+ )
+ drawer.ellipse(outlineShape, fill=self.color)
+ drawer.ellipse(smallerShape, fill=(0, 0, 0, 0))
+ drawer.rectangle(line, fill=self.color)
+
+ def slantLine(difference):
+ return (
+ (drawPtX + difference),
+ (drawPtY + self.pxHeight - qY),
+ ), (
+ (drawPtX + hX),
+ (drawPtY + hY),
+ )
+
+ drawer.line(slantLine(qX), fill=self.color, width=tenthX)
+ drawer.line(slantLine(self.pxWidth - qX), fill=self.color, width=tenthX)
+
+ for x, y in grid:
+ drawPtX = x * self.pxWidth
+ if drawPtX > self.width:
+ continue
+ drawPtY = y * self.pxHeight
+ if drawPtY > self.height:
+ continue
+
+ if self.customImg:
+ drawCustomImg()
+ else:
+ drawShape()
+
+ if self.shadow:
+ shadImg = ImageEnhance.Contrast(frame).enhance(0.0)
+ shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00))
+ shadImg = ImageChops.offset(shadImg, -2, 2)
+ shadImg.paste(frame, box=(0, 0), mask=frame)
+ frame = shadImg
+ if self.showGrid:
+ drawer = ImageDraw.Draw(frame)
+ w, h = scale(0.05, self.width, self.height, int)
+ for x in range(self.pxWidth, self.width, self.pxWidth):
+ drawer.rectangle(
+ ((x, 0), (x + w, self.height)),
+ fill=self.color,
+ )
+ for y in range(self.pxHeight, self.height, self.pxHeight):
+ drawer.rectangle(
+ ((0, y), (self.width, y + h)),
+ fill=self.color,
+ )
+
+ return frame
+
+ def gridForTick(self, tick):
+ """
+ Given a tick number over 0, returns a new grid (a set of tuples).
+ This must compute the previous ticks' grids if not already computed
+ """
+ if tick - 1 not in self.tickGrids:
+ self.tickGrids[tick - 1] = self.gridForTick(tick - 1)
+
+ lastGrid = self.tickGrids[tick - 1]
+
+ def neighbours(x, y):
+ return {cell for cell in self.nearbyCoords(x, y) if cell in lastGrid}
+
+ newGrid = set()
+ # Copy cells from the previous grid if they have 2 or 3 neighbouring cells
+ # and if they are within the grid or its bleed area (off-canvas area)
+ for x, y in lastGrid:
+ if (
+ -self.bleedSize > x > self.gridWidth + self.bleedSize
+ or -self.bleedSize > y > self.gridHeight + self.bleedSize
+ ):
+ continue
+ surrounding = len(neighbours(x, y))
+ if surrounding == 2 or surrounding == 3:
+ newGrid.add((x, y))
+
+ # Find positions around living cells which must be checked for reproduction
+ potentialNewCells = {
+ coordTup
+ for origin in lastGrid
+ for coordTup in list(self.nearbyCoords(*origin))
+ }
+ # Check for reproduction
+ for x, y in potentialNewCells:
+ if (x, y) in newGrid:
+ # Ignore non-empty cell
+ continue
+ surrounding = len(neighbours(x, y))
+ if surrounding == 3:
+ newGrid.add((x, y))
+
+ return newGrid
+
+ def savePreset(self):
+ pr = super().savePreset()
+ pr["GRID"] = sorted(self.startingGrid)
+ return pr
+
+ def loadPreset(self, pr, *args):
+ self.startingGrid = set(pr["GRID"])
+ if self.startingGrid:
+ for widget in self.shiftButtons:
+ widget.setEnabled(True)
+ super().loadPreset(pr, *args)
+
+ def nearbyCoords(self, x, y):
+ yield x + 1, y + 1
+ yield x + 1, y - 1
+ yield x - 1, y + 1
+ yield x - 1, y - 1
+ yield x, y + 1
+ yield x, y - 1
+ yield x + 1, y
+ yield x - 1, y
+
+
+class ClickGrid(QUndoCommand):
+ def __init__(self, comp, pos, button):
+ super().__init__("click %s component #%s" % (comp.name, comp.compPos))
+ self.comp = comp
+ self.pos = [pos]
+ if button == QtCore.Qt.MouseButton.RightButton:
+ self.button = 2
+ else:
+ self.button = 1
+
+ def id(self):
+ return self.button
+
+ def mergeWith(self, other):
+ self.pos.extend(other.pos)
+ return True
+
+ def add(self):
+ for pos in self.pos[:]:
+ self.comp.startingGrid.add(pos)
+ self.comp.update(auto=True)
+
+ def remove(self):
+ for pos in self.pos[:]:
+ self.comp.startingGrid.discard(pos)
+ self.comp.update(auto=True)
+
+ def redo(self):
+ if self.button == 1: # Left-click
+ self.add()
+ elif self.button == 2: # Right-click
+ self.remove()
+
+ def undo(self):
+ if self.button == 1: # Left-click
+ self.remove()
+ elif self.button == 2: # Right-click
+ self.add()
+
+
+class ShiftGrid(QUndoCommand):
+ def __init__(self, comp, direction):
+ super().__init__("change %s component #%s" % (comp.name, comp.compPos))
+ self.comp = comp
+ self.direction = direction
+ self.distance = 1
+
+ def id(self):
+ return self.direction
+
+ def mergeWith(self, other):
+ self.distance += other.distance
+ return True
+
+ def newGrid(self, Xchange, Ychange):
+ return {(x + Xchange, y + Ychange) for x, y in self.comp.startingGrid}
+
+ def redo(self):
+ if self.direction == 0:
+ newGrid = self.newGrid(0, -self.distance)
+ elif self.direction == 1:
+ newGrid = self.newGrid(0, self.distance)
+ elif self.direction == 2:
+ newGrid = self.newGrid(-self.distance, 0)
+ elif self.direction == 3:
+ newGrid = self.newGrid(self.distance, 0)
+ self.comp.startingGrid = newGrid
+ self.comp._sendUpdateSignal()
+
+ def undo(self):
+ if self.direction == 0:
+ newGrid = self.newGrid(0, self.distance)
+ elif self.direction == 1:
+ newGrid = self.newGrid(0, -self.distance)
+ elif self.direction == 2:
+ newGrid = self.newGrid(self.distance, 0)
+ elif self.direction == 3:
+ newGrid = self.newGrid(-self.distance, 0)
+ self.comp.startingGrid = newGrid
+ self.comp._sendUpdateSignal()