Files
BondGraph/BEdit/src/bedit/gui/graphics/icon_editor.py

562 lines
23 KiB
Python

from copy import deepcopy
from PySide6.QtCore import QPointF, QRectF, QSizeF, Qt, QTimer
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import (
QColorDialog,
QButtonGroup,
QDialog,
QGraphicsEllipseItem,
QGraphicsItem,
QGraphicsObject,
QGraphicsPathItem,
QGraphicsScene,
QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
QInputDialog,
QMenu,
)
from bedit.core.model import Component, Icon, Port
from bedit.gui.generated.ui_icon_editor_dialog import Ui_IconEditorDialog
from bedit.gui.generated.ui_shape_options_dialog import Ui_ShapeOptionsDialog
from bedit.gui.graphics.icon_canvas import icon_grid_size
from bedit.gui.graphics.icon_renderer import shape_pen
def _icon_grid_size() -> int:
return icon_grid_size()
def _snap(value: float) -> float:
grid = _icon_grid_size()
return round(value / grid) * grid
class ResizeHandle(QGraphicsEllipseItem):
CURSORS = {
"n": Qt.CursorShape.SizeVerCursor,
"s": Qt.CursorShape.SizeVerCursor,
"e": Qt.CursorShape.SizeHorCursor,
"w": Qt.CursorShape.SizeHorCursor,
"nw": Qt.CursorShape.SizeFDiagCursor,
"se": Qt.CursorShape.SizeFDiagCursor,
"ne": Qt.CursorShape.SizeBDiagCursor,
"sw": Qt.CursorShape.SizeBDiagCursor,
}
def __init__(self, owner: "ShapeItem", role: str = "se") -> None:
super().__init__(-4, -4, 8, 8, owner)
self.owner = owner
self.role = role
self.setBrush(QColor("#ffffff"))
self.setPen(QPen(QColor("#2563eb"), 1.5))
self.setCursor(self.CURSORS[role])
self.setZValue(20)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.begin_resize()
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.resize_from_handle(self.role, event.scenePos())
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.owner.finish_resize()
event.accept()
class ShapeOptionsDialog(QDialog):
def __init__(self, element: dict, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_ShapeOptionsDialog()
self.ui.setupUi(self)
self.element = deepcopy(element)
self.stroke_color = self.element.get("stroke", "#303030")
fill = self.element.get("fill", "#ffffff")
self.fill_color = "#ffffff" if fill in {"none", "transparent", ""} else fill
self.ui.widthSpin.setValue(float(self.element.get("width", 20)))
self.ui.heightSpin.setValue(float(self.element.get("height", 20)))
self.ui.lineStyleCombo.setCurrentText(self.element.get("lineStyle", "solid"))
self.ui.lineWidthSpin.setValue(float(self.element.get("lineWidth", 1.5)))
self.ui.fillTypeCombo.setCurrentText(
"none" if fill in {"none", "transparent", ""} else "solid"
)
self.ui.cornerRadiusSpin.setValue(float(self.element.get("cornerRadius", 0)))
self.ui.textEdit.setText(str(self.element.get("text", "Text")))
self.ui.fontSizeSpin.setValue(float(self.element.get("fontSize", 12)))
self._show_row(
self.ui.fillTypeLabel, self.ui.fillTypeCombo, self.element.get("type") != "line"
)
self._show_row(
self.ui.fillColorLabel, self.ui.fillColorButton, self.element.get("type") != "line"
)
self._show_row(
self.ui.cornerRadiusLabel,
self.ui.cornerRadiusSpin,
self.element.get("type") == "rectangle",
)
is_text = self.element.get("type") == "text"
self._show_row(self.ui.textLabel, self.ui.textEdit, is_text)
self._show_row(self.ui.fontSizeLabel, self.ui.fontSizeSpin, is_text)
self.ui.strokeColorButton.clicked.connect(self._choose_stroke)
self.ui.fillColorButton.clicked.connect(self._choose_fill)
self._refresh_color_buttons()
@staticmethod
def _show_row(label, field, visible: bool) -> None:
label.setVisible(visible)
field.setVisible(visible)
def _choose_color(self, current: str) -> str:
color = QColorDialog.getColor(
QColor(current),
self,
"Choose colour",
QColorDialog.ColorDialogOption.ShowAlphaChannel,
)
if not color.isValid():
return current
return color.name(QColor.NameFormat.HexArgb) if color.alpha() < 255 else color.name()
def _choose_stroke(self) -> None:
self.stroke_color = self._choose_color(self.stroke_color)
self._refresh_color_buttons()
def _choose_fill(self) -> None:
self.fill_color = self._choose_color(self.fill_color)
self._refresh_color_buttons()
def _refresh_color_buttons(self) -> None:
for button, color in (
(self.ui.strokeColorButton, self.stroke_color),
(self.ui.fillColorButton, self.fill_color),
):
button.setText(color)
button.setStyleSheet(f"QPushButton {{ background: {color}; }}")
def accept(self) -> None:
self.element["lineStyle"] = self.ui.lineStyleCombo.currentText()
self.element["lineWidth"] = self.ui.lineWidthSpin.value()
self.element["stroke"] = self.stroke_color
self.element["width"] = self.ui.widthSpin.value()
self.element["height"] = self.ui.heightSpin.value()
if self.element.get("type") != "line":
self.element["fill"] = (
self.fill_color if self.ui.fillTypeCombo.currentText() == "solid" else "none"
)
if self.element.get("type") == "rectangle":
self.element["cornerRadius"] = self.ui.cornerRadiusSpin.value()
if self.element.get("type") == "text":
self.element["text"] = self.ui.textEdit.text()
self.element["fontSize"] = self.ui.fontSizeSpin.value()
self.element["color"] = self.stroke_color
super().accept()
class ShapeItem(QGraphicsObject):
def __init__(self, element: dict) -> None:
super().__init__()
self.element = element
self.setPos(float(element.get("x", 0)), float(element.get("y", 0)))
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
roles = (
("n", "ne", "e", "se", "s", "sw", "w", "nw")
if element.get("type") == "rectangle"
else ("se",)
)
self.resize_handles = [ResizeHandle(self, role) for role in roles]
self.resize_start: dict | None = None
self._position_resize_handles()
for handle in self.resize_handles:
handle.hide()
def boundingRect(self) -> QRectF: # noqa: N802
margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
return (
QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
.normalized()
.adjusted(-margin, -margin, margin, margin)
)
def paint(self, painter: QPainter, option, widget=None) -> None:
del option, widget
rect = QRectF(
0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))
)
painter.setPen(shape_pen(self.element))
fill = self.element.get("fill", "none")
painter.setBrush(
Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)
)
kind = self.element.get("type")
if kind == "rectangle":
radius = float(self.element.get("cornerRadius", 0))
painter.drawRoundedRect(rect, radius, radius)
elif kind in {"circle", "ellipse"}:
painter.drawEllipse(rect)
elif kind == "line":
painter.drawLine(rect.topLeft(), rect.bottomRight())
elif kind == "triangle":
painter.drawPolygon(
QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()])
)
elif kind == "text":
painter.setPen(QColor(self.element.get("color", "#202020")))
font = painter.font()
font.setPointSizeF(float(self.element.get("fontSize", 12)))
painter.setFont(font)
painter.drawText(
rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text"))
)
if self.isSelected():
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
painter.drawRect(rect)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
value = self._bounded_position(value)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.element["x"], self.element["y"] = value.x(), value.y()
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
for handle in self.resize_handles:
handle.setVisible(bool(value))
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
self.setPos(self._bounded_position(self.pos()))
def _bounded_position(self, position: QPointF) -> QPointF:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
width = float(self.element.get("width", 20))
height = float(self.element.get("height", 20))
minimum_x, maximum_x = min(0.0, width), max(0.0, width)
minimum_y, maximum_y = min(0.0, height), max(0.0, height)
return QPointF(
max(bounds.left() - minimum_x, min(bounds.right() - maximum_x, _snap(position.x()))),
max(bounds.top() - minimum_y, min(bounds.bottom() - maximum_y, _snap(position.y()))),
)
def _position_resize_handles(self) -> None:
width = float(self.element.get("width", 20))
height = float(self.element.get("height", 20))
positions = {
"n": QPointF(width / 2, 0),
"ne": QPointF(width, 0),
"e": QPointF(width, height / 2),
"se": QPointF(width, height),
"s": QPointF(width / 2, height),
"sw": QPointF(0, height),
"w": QPointF(0, height / 2),
"nw": QPointF(0, 0),
}
for handle in self.resize_handles:
handle.setPos(positions[handle.role])
def begin_resize(self) -> None:
self.resize_start = {
"left": self.pos().x(),
"top": self.pos().y(),
"right": self.pos().x() + float(self.element.get("width", 20)),
"bottom": self.pos().y() + float(self.element.get("height", 20)),
}
def resize_from_handle(self, role: str, scene_position: QPointF) -> None:
if self.resize_start is None:
self.begin_resize()
if self.element.get("type") == "line":
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
endpoint = QPointF(
min(bounds.right(), max(bounds.left(), _snap(scene_position.x()))),
min(bounds.bottom(), max(bounds.top(), _snap(scene_position.y()))),
)
self.prepareGeometryChange()
self.element["width"] = endpoint.x() - self.pos().x()
self.element["height"] = endpoint.y() - self.pos().y()
self._position_resize_handles()
self.update()
return
edges = dict(self.resize_start)
point = QPointF(_snap(scene_position.x()), _snap(scene_position.y()))
if "w" in role:
edges["left"] = min(point.x(), edges["right"] - _icon_grid_size())
if "e" in role:
edges["right"] = max(point.x(), edges["left"] + _icon_grid_size())
if "n" in role:
edges["top"] = min(point.y(), edges["bottom"] - _icon_grid_size())
if "s" in role:
edges["bottom"] = max(point.y(), edges["top"] + _icon_grid_size())
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
edges["left"] = max(bounds.left(), edges["left"])
edges["top"] = max(bounds.top(), edges["top"])
edges["right"] = min(bounds.right(), edges["right"])
edges["bottom"] = min(bounds.bottom(), edges["bottom"])
if self.element.get("type") == "circle":
side = min(edges["right"] - edges["left"], edges["bottom"] - edges["top"])
edges["right"] = edges["left"] + side
edges["bottom"] = edges["top"] + side
self.prepareGeometryChange()
self.element["width"] = edges["right"] - edges["left"]
self.element["height"] = edges["bottom"] - edges["top"]
self.setPos(edges["left"], edges["top"])
self._position_resize_handles()
self.update()
def finish_resize(self) -> None:
self.resize_start = None
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu()
options = menu.addAction("Shape Options…")
delete = menu.addAction("Delete Shape")
chosen = menu.exec(event.screenPos())
if chosen is options:
options_element = deepcopy(self.element)
width_sign = -1 if float(options_element.get("width", 20)) < 0 else 1
height_sign = -1 if float(options_element.get("height", 20)) < 0 else 1
options_element["width"] = abs(float(options_element.get("width", 20)))
options_element["height"] = abs(float(options_element.get("height", 20)))
dialog = ShapeOptionsDialog(options_element)
if dialog.exec() == dialog.DialogCode.Accepted:
self.prepareGeometryChange()
self.element.clear()
self.element.update(dialog.element)
if self.element.get("type") == "line":
self.element["width"] *= width_sign
self.element["height"] *= height_sign
self._position_resize_handles()
self.update()
elif chosen is delete and self.scene() is not None:
self.scene().removeItem(self)
self.element["_deleted"] = True
event.accept()
class PortHandle(QGraphicsEllipseItem):
def __init__(self, port: Port, direction: str, position: QPointF) -> None:
super().__init__(-5, -5, 10, 10)
self.port, self.direction = port, direction
self.setPos(position)
self.setBrush(QColor("#16a34a" if direction == "input" else "#dc2626"))
self.setPen(QPen(QColor("#ffffff"), 1.5))
self.setToolTip(f"{direction.title()}: {port.name} (drag to position)")
self.setZValue(10)
self.setFlags(
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
def itemChange(self, change, value): # noqa: N802
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
value = QPointF(
min(bounds.right(), max(bounds.left(), _snap(value.x()))),
min(bounds.bottom(), max(bounds.top(), _snap(value.y()))),
)
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.port.properties["iconPosition"] = {"x": value.x(), "y": value.y()}
return super().itemChange(change, value)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event)
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
self.setPos(
min(bounds.right(), max(bounds.left(), _snap(self.pos().x()))),
min(bounds.bottom(), max(bounds.top(), _snap(self.pos().y()))),
)
class IconDrawingScene(QGraphicsScene):
def __init__(self, editor: "IconEditorDialog", rect: QRectF) -> None:
super().__init__(rect, editor)
self.editor = editor
self.mode = "pointer"
self.start: QPointF | None = None
self.preview: QGraphicsPathItem | None = None
def set_mode(self, mode: str) -> None:
self.mode = mode
self._clear_preview()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.mode == "pointer":
super().mousePressEvent(event)
return
point = self._bounded(event.scenePos())
if self.mode == "text":
text, accepted = QInputDialog.getText(self.editor, "Add Text", "Text:", text="Text")
if accepted:
self.editor.create_shape("text", point, point + QPointF(55, 35), text)
event.accept()
return
self.start = point
self.preview = QGraphicsPathItem()
self.preview.setPen(QPen(QColor("#64748b"), 1.5, Qt.PenStyle.DashLine))
self.preview.setZValue(100)
self.addItem(self.preview)
event.accept()
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None or self.preview is None:
super().mouseMoveEvent(event)
return
end = self._bounded(event.scenePos())
path = QPainterPath()
rect = QRectF(self.start, end).normalized()
if self.mode == "line":
path.moveTo(self.start)
path.lineTo(end)
elif self.mode in {"circle", "ellipse"}:
if self.mode == "circle":
side = min(rect.width(), rect.height())
rect.setSize(QSizeF(side, side))
path.addEllipse(rect)
else:
path.addRect(rect)
self.preview.setPath(path)
event.accept()
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
if self.start is None:
super().mouseReleaseEvent(event)
return
end = self._bounded(event.scenePos())
start = self.start
self._clear_preview()
if start != end:
self.editor.create_shape(self.mode, start, end)
event.accept()
def _bounded(self, point: QPointF) -> QPointF:
rect = self.sceneRect()
return QPointF(
min(rect.right(), max(rect.left(), _snap(point.x()))),
min(rect.bottom(), max(rect.top(), _snap(point.y()))),
)
def _clear_preview(self) -> None:
if self.preview is not None:
self.removeItem(self.preview)
self.preview = None
self.start = None
class IconEditorDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None:
super().__init__(parent)
self.ui = Ui_IconEditorDialog()
self.ui.setupUi(self)
self.setWindowTitle(f"Icon Editor — {component.name}")
self.icon = Icon.from_dict(component.icon.to_dict())
self.ports = deepcopy(component.ports)
self.tool_group = QButtonGroup(self)
self.tool_group.setExclusive(True)
self.tool_group.addButton(self.ui.pointerButton)
self.ui.pointerButton.clicked.connect(lambda: self.set_draw_mode("pointer"))
for button, kind in (
(self.ui.addRectangleButton, "rectangle"),
(self.ui.addCircleButton, "circle"),
(self.ui.addEllipseButton, "ellipse"),
(self.ui.addLineButton, "line"),
(self.ui.addTriangleButton, "triangle"),
(self.ui.addTextButton, "text"),
):
self.tool_group.addButton(button)
button.clicked.connect(lambda _checked=False, value=kind: self.set_draw_mode(value))
self.ui.deleteSelectedButton.clicked.connect(self.delete_selected)
self.scene = IconDrawingScene(self, QRectF(0, 0, self.icon.width, self.icon.height))
self.ui.iconView.setScene(self.scene)
self.view = self.ui.iconView
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.ui.zoomInButton.clicked.connect(self.view.zoom_in)
self.ui.zoomOutButton.clicked.connect(self.view.zoom_out)
self.ui.centerButton.clicked.connect(self.view.center_icon)
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
for element in self.icon.elements:
self.scene.addItem(ShapeItem(element))
input_side = [port for port in self.ports if port.orientation != "output"]
output_side = [port for port in self.ports if port.orientation == "output"]
self._add_ports(input_side, "input", 0.0)
self._add_ports(output_side, "output", self.icon.width)
self._initial_fit_pending = True
def showEvent(self, event) -> None: # noqa: N802 (Qt API name)
super().showEvent(event)
if self._initial_fit_pending:
self._initial_fit_pending = False
QTimer.singleShot(0, self.view.center_icon)
def _add_ports(self, ports: list[Port], direction: str, default_x: float) -> None:
spacing = self.icon.height / (len(ports) + 1)
for index, port in enumerate(ports, 1):
saved = port.properties.get("iconPosition", {})
position = QPointF(
float(saved.get("x", default_x)), float(saved.get("y", spacing * index))
)
self.scene.addItem(PortHandle(port, direction, position))
def set_draw_mode(self, mode: str) -> None:
self.scene.set_mode(mode)
self.view.setDragMode(
self.view.DragMode.RubberBandDrag if mode == "pointer" else self.view.DragMode.NoDrag
)
def create_shape(self, kind: str, start: QPointF, end: QPointF, text: str = "Text") -> None:
left, right = sorted((start.x(), end.x()))
top, bottom = sorted((start.y(), end.y()))
element = {
"type": kind,
"x": left,
"y": top,
"width": max(_icon_grid_size(), right - left),
"height": max(_icon_grid_size(), bottom - top),
"fill": "#dbeafe",
"stroke": "#303030",
"lineWidth": 1.5,
"lineStyle": "solid",
}
if kind == "circle":
element["width"] = element["height"] = min(element["width"], element["height"])
if kind == "line":
element["x"], element["y"] = start.x(), start.y()
element["width"], element["height"] = end.x() - start.x(), end.y() - start.y()
element["fill"] = "none"
if kind == "rectangle":
element["cornerRadius"] = 0
if kind == "text":
element.update(
{
"text": text,
"fontSize": 12,
"color": "#202020",
"fill": "none",
"lineStyle": "none",
}
)
self.icon.elements.append(element)
item = ShapeItem(element)
self.scene.addItem(item)
item.setSelected(True)
def delete_selected(self) -> None:
for item in self.scene.selectedItems():
if isinstance(item, ShapeItem):
self.scene.removeItem(item)
item.element["_deleted"] = True
def accept(self) -> None:
self.icon.elements = [
element for element in self.icon.elements if not element.pop("_deleted", False)
]
super().accept()