Files
bedit/src/bedit_gui/views/icon_graphics_scene.py
T
2026-07-27 14:03:06 +02:00

274 lines
11 KiB
Python

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)