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()