from __future__ import annotations import math from copy import deepcopy from typing import Protocol from PySide6.QtCore import QObject, QPointF, QRectF, Qt, Signal from PySide6.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget from bedit_gui.models import Icon, LineType, Rectangle, Shape, ShapeID class ShapeCreationTool(Protocol): def begin(self, position: QPointF) -> None: ... def update(self, position: QPointF) -> None: ... def finish(self, position: QPointF) -> Shape | None: ... def cancel(self) -> None: ... class RectangleCreationTool: def __init__(self, scene: QGraphicsScene, layer: int) -> None: self.scene = scene self.layer = layer self.start: QPointF | None = None self.preview: QGraphicsRectItem | None = None def begin(self, position: QPointF) -> None: position = self._bounded(position) self.start = position self.preview = self.scene.addRect(QRectF(position, position), QPen(Qt.PenStyle.DashLine)) def update(self, position: QPointF) -> None: if self.preview is not None and self.start is not None: position = self._bounded(position) self.preview.setRect(QRectF(self.start, position).normalized()) def finish(self, position: QPointF) -> Rectangle | None: if self.start is None: return None position = self._bounded(position) rect = QRectF(self.start, position).normalized() self.cancel() if rect.width() < 1 or rect.height() < 1: return None return Rectangle(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height()) def cancel(self) -> None: if self.preview is not None: self.scene.removeItem(self.preview) self.preview = None self.start = None def _bounded(self, position: QPointF) -> QPointF: rect = self.scene.sceneRect() x = max(rect.left(), min(rect.right(), round(position.x()))) y = max(rect.top(), min(rect.bottom(), round(position.y()))) return QPointF(x, y) class ShapeGraphicsItem(QGraphicsPathItem): handle_size = 6.0 def __init__(self, shape_id: ShapeID, shape: Shape, scene: IconGraphicsScene) -> None: super().__init__() self.shape_id = shape_id self.shape_model = deepcopy(shape) self.icon_scene = scene self._original_shape: Shape | None = None self._resizing = False self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) def resize_handle_rect(self) -> QRectF: size = self.handle_size corner = self.path().boundingRect().bottomRight() return QRectF(corner.x() - size / 2, corner.y() - size / 2, size, size) def boundingRect(self) -> QRectF: margin = self.handle_size / 2 return super().boundingRect().adjusted(-margin, -margin, margin, margin) def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: self._original_shape = self.current_shape() self._resizing = self.isSelected() and self.resize_handle_rect().contains(event.pos()) if self._resizing: event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._resizing: self.resize_to(event.scenePos()) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._resizing: self.resize_to(event.scenePos()) self._resizing = False event.accept() else: super().mouseReleaseEvent(event) current = self.current_shape() if self._original_shape is not None and current != self._original_shape: self.icon_scene.shape_changed.emit(self.shape_id, self._original_shape, current) self._original_shape = None def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: if not self.isSelected(): self.icon_scene.clearSelection() self.setSelected(True) menu = QMenu() options = menu.addAction("Shape Options") if menu.exec(event.screenPos()) is options: self.icon_scene.shape_options_requested.emit(self.shape_id) event.accept() def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object: if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None: position = value if isinstance(position, QPointF): bounds = self.icon_scene.sceneRect() size = self.path().boundingRect() x = max(bounds.left(), min(bounds.right() - size.width(), round(position.x()))) y = max(bounds.top(), min(bounds.bottom() - size.height(), round(position.y()))) return QPointF(x, y) return super().itemChange(change, value) def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None: super().paint(painter, option, widget) if self.isSelected(): painter.setPen(QPen(QColor("#ffffff"))) painter.setBrush(QBrush(QColor("#2675bf"))) painter.drawRect(self.resize_handle_rect()) def current_shape(self) -> Shape: raise NotImplementedError def resize_to(self, position: QPointF) -> None: raise NotImplementedError class RectangleGraphicsItem(ShapeGraphicsItem): def __init__(self, shape_id: ShapeID, shape: Rectangle, scene: IconGraphicsScene) -> None: super().__init__(shape_id, shape, scene) self.rectangle = deepcopy(shape) self.setPos(shape.pos[0], shape.pos[1]) self._set_size(shape.width, shape.height) self.setPen(scene._pen(shape)) self.setBrush(QBrush(scene._color(shape.fill_color))) self.setZValue(shape.layer) def current_shape(self) -> Rectangle: shape = deepcopy(self.rectangle) shape.pos = (round(self.pos().x()), round(self.pos().y())) rect = self.path().boundingRect() shape.width = rect.width() shape.height = rect.height() return shape def resize_to(self, position: QPointF) -> None: bounds = self.icon_scene.sceneRect() width = max(1.0, min(bounds.right(), round(position.x())) - self.pos().x()) height = max(1.0, min(bounds.bottom(), round(position.y())) - self.pos().y()) self._set_size(width, height) def _set_size(self, width: float, height: float) -> None: self.prepareGeometryChange() path = QPainterPath() path.addRoundedRect(QRectF(0, 0, width, height), self.rectangle.corner_radius, self.rectangle.corner_radius) self.setPath(path) class IconGraphicsScene(QGraphicsScene): shape_created = Signal(object) shape_changed = Signal(object, object, object) shape_options_requested = Signal(object) tool_active_changed = Signal(bool) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._tool: ShapeCreationTool | None = None self.setSceneRect(-64, -64, 128, 128) def set_icon(self, icon: Icon) -> None: self.cancel_creation_tool() self.clear() for shape_id, shape in sorted(icon.shapes.items(), key=lambda item: item[1].layer): self._add_shape_item(shape_id, shape) def set_creation_tool(self, tool: ShapeCreationTool) -> None: self.cancel_creation_tool() self._tool = tool self.tool_active_changed.emit(True) def cancel_creation_tool(self) -> None: if self._tool is None: return self._tool.cancel() self._tool = None self.tool_active_changed.emit(False) def has_creation_tool(self) -> bool: return self._tool is not None def selected_shape_ids(self) -> list[ShapeID]: return [item.shape_id for item in self.selectedItems() if isinstance(item, ShapeGraphicsItem)] def drawBackground(self, painter: QPainter, rect: QRectF) -> None: super().drawBackground(painter, rect) rect = rect.intersected(self.sceneRect()) if rect.isEmpty(): return pen = QPen(QColor("#d0d0d0")) pen.setCosmetic(True) painter.setPen(pen) first_x = math.floor(rect.left() / 8) * 8 last_x = math.ceil(rect.right() / 8) * 8 first_y = math.floor(rect.top() / 8) * 8 last_y = math.ceil(rect.bottom() / 8) * 8 for x in range(first_x, last_x + 1, 8): painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom())) for y in range(first_y, last_y + 1, 8): painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y)) def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._tool is not None and event.button() == Qt.MouseButton.LeftButton: self._tool.begin(event.scenePos()) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._tool is not None and event.buttons() & Qt.MouseButton.LeftButton: self._tool.update(event.scenePos()) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._tool is not None and event.button() == Qt.MouseButton.LeftButton: tool = self._tool shape = tool.finish(event.scenePos()) self._tool = None self.tool_active_changed.emit(False) if shape is not None: self.shape_created.emit(shape) event.accept() return super().mouseReleaseEvent(event) def _add_shape_item(self, shape_id: ShapeID, shape: Shape) -> None: if not isinstance(shape, Rectangle): return self.addItem(RectangleGraphicsItem(shape_id, shape, self)) @staticmethod def _pen(shape: Rectangle) -> QPen: styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine} if shape.line_type is LineType.NONE: return QPen(Qt.PenStyle.NoPen) return QPen(IconGraphicsScene._color(shape.line_color), shape.line_thickness, styles[shape.line_type]) @staticmethod def _color(value: str) -> QColor: color = value.removeprefix("#") if len(color) == 8: return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16)) return QColor(value)