463 lines
20 KiB
Python
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)
|