Files
BEdit/src/bedit_gui/views/icon_graphics_scene.py
2026-07-27 14:40:48 +02:00

463 lines
20 KiB
Python

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)