from __future__ import annotations import math from copy import deepcopy from typing import Protocol from PySide6.QtCore import QLineF, QObject, QPointF, QRectF, Qt, Signal from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget from bedit_core.models import Port, PortID, SignalDirection from bedit_gui.models import Icon, Line, LineType, Rectangle, Shape, ShapeID, Text 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) -> Shape | 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 self.create_shape(rect) def create_shape(self, rect: QRectF) -> Shape: 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 TextCreationTool(RectangleCreationTool): def create_shape(self, rect: QRectF) -> Shape: return Text(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height(), text="Text") class LineCreationTool: def __init__(self, scene: QGraphicsScene, layer: int) -> None: self.scene = scene self.layer = layer self.start: QPointF | None = None self.preview: QGraphicsLineItem | None = None def begin(self, position: QPointF) -> None: position = self._bounded(position) self.start = position self.preview = self.scene.addLine(QLineF(position, position), QPen(Qt.PenStyle.DashLine)) def update(self, position: QPointF) -> None: if self.preview is not None and self.start is not None: self.preview.setLine(QLineF(self.start, self._bounded(position))) def finish(self, position: QPointF) -> Shape | None: if self.start is None: return None start = self.start end = self._bounded(position) self.cancel() if start == end: return None return Line(layer=self.layer, pos=(round(start.x()), round(start.y())), end=(round(end.x()), round(end.y()))) 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._resize_handle: str | None = None 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 resize_handles(self) -> dict[str, QRectF]: return {"size": self.resize_handle_rect()} 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._resize_handle = next((name for name, rect in self.resize_handles().items() if self.isSelected() and rect.contains(event.pos())), None) if self._resize_handle is not None: event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._resize_handle is not None: self.resize_to(event.scenePos(), self._resize_handle) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._resize_handle is not None: self.resize_to(event.scenePos(), self._resize_handle) self._resize_handle = None 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() shape_rect = self.path().boundingRect() x = max(bounds.left() - shape_rect.left(), min(bounds.right() - shape_rect.right(), round(position.x()))) y = max(bounds.top() - shape_rect.top(), min(bounds.bottom() - shape_rect.bottom(), 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"))) for rect in self.resize_handles().values(): painter.drawRect(rect) def current_shape(self) -> Shape: raise NotImplementedError def resize_to(self, position: QPointF, handle: str) -> 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, _handle: str) -> 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 TextGraphicsItem(ShapeGraphicsItem): def __init__(self, shape_id: ShapeID, shape: Text, scene: IconGraphicsScene) -> None: super().__init__(shape_id, shape, scene) self.text = deepcopy(shape) self.setPos(shape.pos[0], shape.pos[1]) self._set_size(shape.width, shape.height) self.setPen(QPen(Qt.PenStyle.NoPen)) self.setBrush(QBrush(QColor(0, 0, 0, 0))) self.setZValue(shape.layer) def current_shape(self) -> Text: shape = deepcopy(self.text) 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, _handle: str) -> 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 paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None: super().paint(painter, option, widget) font = QFont() font.setPixelSize(max(1, round(self.text.size))) font.setBold(self.text.bold) font.setItalic(self.text.italic) painter.setFont(font) painter.setPen(self.icon_scene._color(self.text.color)) painter.setClipRect(self.path().boundingRect()) painter.drawText(self.path().boundingRect(), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.text.text) def _set_size(self, width: float, height: float) -> None: self.prepareGeometryChange() path = QPainterPath() path.addRect(QRectF(0, 0, width, height)) self.setPath(path) class LineGraphicsItem(ShapeGraphicsItem): def __init__(self, shape_id: ShapeID, shape: Line, scene: IconGraphicsScene) -> None: super().__init__(shape_id, shape, scene) self.line = deepcopy(shape) self._set_points(QPointF(shape.pos[0], shape.pos[1]), QPointF(shape.end[0], shape.end[1])) self.setPen(scene._line_pen(shape.line_type, shape.line_thickness, shape.line_color)) self.setZValue(shape.layer) def resize_handles(self) -> dict[str, QRectF]: size = self.handle_size start = self.path().pointAtPercent(0) end = self.path().pointAtPercent(1) return {"start": QRectF(start.x() - size / 2, start.y() - size / 2, size, size), "end": QRectF(end.x() - size / 2, end.y() - size / 2, size, size)} def current_shape(self) -> Line: shape = deepcopy(self.line) start = self.mapToScene(self.path().pointAtPercent(0)) end = self.mapToScene(self.path().pointAtPercent(1)) shape.pos = (round(start.x()), round(start.y())) shape.end = (round(end.x()), round(end.y())) return shape def resize_to(self, position: QPointF, handle: str) -> None: bounds = self.icon_scene.sceneRect() position = QPointF(max(bounds.left(), min(bounds.right(), round(position.x()))), max(bounds.top(), min(bounds.bottom(), round(position.y())))) start = self.mapToScene(self.path().pointAtPercent(0)) end = self.mapToScene(self.path().pointAtPercent(1)) start = position if handle == "start" else start end = position if handle == "end" else end self.setPos(0, 0) self._set_points(start, end) def _set_points(self, start: QPointF, end: QPointF) -> None: self.prepareGeometryChange() path = QPainterPath(start) path.lineTo(end) self.setPath(path) class PortGraphicsItem(QGraphicsRectItem): size = 16.0 def __init__(self, port_id: PortID, port: Port, position: tuple[int, int], scene: IconGraphicsScene) -> None: super().__init__(0, 0, self.size, self.size) self.port_id = port_id self.icon_scene = scene self._original_position: tuple[int, int] | None = None self.setPos(position[0], position[1]) self.setPen(QPen(QColor("#000000"))) self.setBrush(QBrush(QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff"))) self.setZValue(1000000) self.setToolTip(port.name) self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: self._original_position = self.position() super().mousePressEvent(event) def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: super().mouseReleaseEvent(event) position = self.position() if self._original_position is not None and position != self._original_position: self.icon_scene.port_moved.emit(self.port_id, self._original_position, position) self._original_position = None 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() x = max(bounds.left(), min(bounds.right() - self.size, round(position.x()))) y = max(bounds.top(), min(bounds.bottom() - self.size, round(position.y()))) return QPointF(x, y) return super().itemChange(change, value) def position(self) -> tuple[int, int]: return (round(self.pos().x()), round(self.pos().y())) class IconGraphicsScene(QGraphicsScene): port_moved = Signal(object, object, object) 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._ports: dict[PortID, Port] = {} self.setSceneRect(-64, -64, 128, 128) def set_ports(self, ports: dict[PortID, Port]) -> None: self._ports = deepcopy(ports) 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) for port_id, port in self._ports.items(): position = icon.port_positions.get(port_id) if position is not None: self.addItem(PortGraphicsItem(port_id, port, position, self)) 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 isinstance(shape, Rectangle): self.addItem(RectangleGraphicsItem(shape_id, shape, self)) elif isinstance(shape, Text): self.addItem(TextGraphicsItem(shape_id, shape, self)) elif isinstance(shape, Line): self.addItem(LineGraphicsItem(shape_id, shape, self)) @staticmethod def _pen(shape: Rectangle) -> QPen: return IconGraphicsScene._line_pen(shape.line_type, shape.line_thickness, shape.line_color) @staticmethod def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen: styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine} if line_type is LineType.NONE: return QPen(Qt.PenStyle.NoPen) return QPen(IconGraphicsScene._color(color), thickness, styles[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)