diff --git a/BEdit/README.md b/BEdit/README.md index a815fb0..834a60a 100644 --- a/BEdit/README.md +++ b/BEdit/README.md @@ -134,14 +134,22 @@ Every component owns its ports, declarative icon, properties, and child graph: "name": "My Component", "position": {"x": 0, "y": 0}, "interface": { - "inputs": [{"id": "in", "name": "Input"}], - "outputs": [{"id": "out", "name": "Output"}] + "inputs": [{"id": "in", "name": "Input", "properties": { + "iconPosition": {"x": 0, "y": 40} + }}], + "outputs": [{"id": "out", "name": "Output", "properties": { + "iconPosition": {"x": 120, "y": 40} + }}] }, "icon": { - "shape": "rectangle", - "fill": "#dbeafe", - "border": "#245c9c", - "text": "Component" + "size": {"width": 120, "height": 80}, + "elements": [{ + "type": "rectangle", "x": 1, "y": 1, + "width": 118, "height": 78, + "cornerRadius": 5, + "fill": "#dbeafe", "stroke": "#245c9c", + "lineWidth": 1.5, "lineStyle": "solid" + }] }, "properties": {}, "implementation": { @@ -157,8 +165,10 @@ Every component owns its ports, declarative icon, properties, and child graph: Graph components use `"implementation": {"kind": "graph", "graph": ...}`; text components use `"implementation": {"kind": "text", "source": ...}` and -never own a graph. Supported icon shapes are currently `rectangle` and -`ellipse`. The recursive model is under `src/bedit/document/`, library loading +never own a graph. Vector icons can contain rectangles, circles, ellipses, +lines, triangles, and text. Each element owns its geometry, fill, stroke, and +line style; ports keep their icon anchor in `properties.iconPosition`. The +recursive model is under `src/bedit/document/`, library loading and the live Current Document tree are under `src/bedit/library/`, and graphics are isolated under `src/bedit/workspace/`. diff --git a/BEdit/src/bedit/component_options_dialog.py b/BEdit/src/bedit/component_options_dialog.py index 85bc9af..b1252ef 100644 --- a/BEdit/src/bedit/component_options_dialog.py +++ b/BEdit/src/bedit/component_options_dialog.py @@ -1,7 +1,7 @@ -from PySide6.QtGui import QColor -from PySide6.QtWidgets import QDialog, QMessageBox +from PySide6.QtWidgets import QDialog, QMessageBox, QPushButton from bedit.document.model import Component +from bedit.icon_editor import IconEditorDialog from bedit.ui_component_options_dialog import Ui_ComponentOptionsDialog @@ -10,22 +10,36 @@ class ComponentOptionsDialog(QDialog): super().__init__(parent) self.ui = Ui_ComponentOptionsDialog() self.ui.setupUi(self) + self.component = component + self.edited_icon = component.icon + self.edited_inputs = component.inputs + self.edited_outputs = component.outputs self.ui.nameEdit.setText(component.name) - self.ui.shapeCombo.setCurrentText(component.icon.shape) - self.ui.iconTextEdit.setText(component.icon.text) - self.ui.fillEdit.setText(component.icon.fill) - self.ui.borderEdit.setText(component.icon.border) + for widget in ( + self.ui.shapeLabel, self.ui.shapeCombo, self.ui.iconTextLabel, + self.ui.iconTextEdit, self.ui.fillLabel, self.ui.fillEdit, + self.ui.borderLabel, self.ui.borderEdit, + ): + widget.hide() + self.icon_editor_button = QPushButton("Edit Icon…", self) + self.icon_editor_button.setToolTip("Open the vector icon and port-position editor") + self.icon_editor_button.clicked.connect(self.edit_icon) + self.ui.optionsForm.insertRow(1, "Icon:", self.icon_editor_button) self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library) + def edit_icon(self) -> None: + working = Component.from_dict(self.component.to_dict()) + working.icon = self.edited_icon + working.inputs = self.edited_inputs + working.outputs = self.edited_outputs + dialog = IconEditorDialog(working, self) + if dialog.exec() == dialog.DialogCode.Accepted: + self.edited_icon = dialog.icon + self.edited_inputs = dialog.inputs + self.edited_outputs = dialog.outputs + def accept(self) -> None: if not self.ui.nameEdit.text().strip(): QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.") return - for label, value in ( - ("fill", self.ui.fillEdit.text()), - ("border", self.ui.borderEdit.text()), - ): - if not QColor(value).isValid(): - QMessageBox.warning(self, "Invalid color", f"The {label} color is not valid.") - return super().accept() diff --git a/BEdit/src/bedit/data/libraries/example.json b/BEdit/src/bedit/data/libraries/example.json deleted file mode 100644 index 39da028..0000000 --- a/BEdit/src/bedit/data/libraries/example.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "format": "bedit-document", - "version": 1, - "metadata": {"name": "Example Library"}, - "roots": [ - { - "id": "example-a", - "name": "Block A", - "position": {"x": 0, "y": 0}, - "interface": { - "inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}], - "outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}] - }, - "icon": {"shape": "rectangle", "fill": "#dbeafe", "border": "#245c9c", "text": "A"}, - "properties": {}, - "library": {"showSubtree": true}, - "implementation": { - "kind": "text", - "source": {"equations": ["out = gain * in"], "parameters": {"gain": 1.0}} - } - }, - { - "id": "example-b", - "name": "Block B", - "position": {"x": 0, "y": 0}, - "interface": { - "inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}], - "outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}] - }, - "icon": {"shape": "ellipse", "fill": "#dcfce7", "border": "#277342", "text": "B"}, - "properties": {}, - "library": {"showSubtree": true}, - "implementation": { - "kind": "text", - "source": {"equations": ["out = in + offset"], "parameters": {"offset": 0.0}} - } - }, - { - "id": "example-c", - "name": "Block C", - "position": {"x": 0, "y": 0}, - "interface": { - "inputs": [{"id": "in", "name": "Input", "position": {"x": 32, "y": 96}}], - "outputs": [{"id": "out", "name": "Output", "position": {"x": 320, "y": 96}}] - }, - "icon": {"shape": "rectangle", "fill": "#fef3c7", "border": "#8a641c", "text": "C"}, - "properties": {}, - "library": {"showSubtree": true}, - "implementation": {"kind": "graph", "graph": {"blocks": [], "connections": []}} - } - ] -} diff --git a/BEdit/src/bedit/data/libraries/test.bedit.json b/BEdit/src/bedit/data/libraries/test.bedit.json deleted file mode 100644 index 88faea7..0000000 --- a/BEdit/src/bedit/data/libraries/test.bedit.json +++ /dev/null @@ -1,351 +0,0 @@ -{ - "format": "bedit-document", - "version": 1, - "metadata": { - "name": "Untitled" - }, - "roots": [ - { - "id": "97cd3d0b-c36a-467e-b1f5-4c794979bd99", - "name": "something", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "in", - "name": "Input", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": {} - } - ], - "outputs": [] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Something" - }, - "properties": {}, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": [], - "parameters": {} - } - } - }, - { - "id": "614b4018-c00e-40ce-b59c-5d5795eab7d0", - "name": "Test", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [], - "outputs": [] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Test" - }, - "properties": {}, - "library": { - "showSubtree": false - }, - "implementation": { - "kind": "graph", - "graph": { - "blocks": [ - { - "id": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce", - "name": "sin", - "position": { - "x": -28.0, - "y": -179.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "input-0bcb214f", - "name": "a", - "position": { - "x": -223.0, - "y": -161.0 - }, - "properties": {} - } - ], - "outputs": [ - { - "id": "output-a55cd289", - "name": "b", - "position": { - "x": 133.0, - "y": -152.0 - }, - "properties": {} - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "sin()" - }, - "properties": {}, - "library": { - "showSubtree": false - }, - "implementation": { - "kind": "graph", - "graph": { - "blocks": [], - "connections": [ - { - "id": "cef30af9-fdc5-4a28-a7d6-4ab3c83e65d7", - "source": { - "interface": "input-0bcb214f" - }, - "target": { - "interface": "output-a55cd289" - }, - "name": "", - "properties": {} - } - ] - } - } - }, - { - "id": "88f40d88-f62b-432f-a6a0-95513b48409e", - "name": "constant", - "position": { - "x": -260.0, - "y": -178.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [], - "outputs": [ - { - "id": "output-1e15ff3f", - "name": "Output 1", - "position": { - "x": 71.0, - "y": -134.0 - }, - "properties": {} - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "C" - }, - "properties": {}, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "graph", - "graph": { - "blocks": [], - "connections": [] - } - } - }, - { - "id": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5", - "name": "New Text Block 1", - "position": { - "x": 225.0, - "y": -167.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "in", - "name": "Input", - "position": { - "x": 0.0, - "y": 0.0 - }, - "properties": {} - } - ], - "outputs": [] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "Text" - }, - "properties": {}, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "text", - "source": { - "equations": [], - "parameters": {} - } - } - } - ], - "connections": [ - { - "id": "b3837790-26ba-46a6-91f9-3b6808c7ad1a", - "source": { - "block": "88f40d88-f62b-432f-a6a0-95513b48409e", - "port": "output-1e15ff3f" - }, - "target": { - "block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce", - "port": "input-0bcb214f" - }, - "name": "", - "properties": {} - }, - { - "id": "50b2c7aa-d824-407c-ae0f-6b3bb4b1b79e", - "source": { - "block": "4ad9ab39-f332-4a9e-ae1a-02141b90a4ce", - "port": "output-a55cd289" - }, - "target": { - "block": "88941f2d-8edd-4dd9-a07b-3f206f6b76c5", - "port": "in" - }, - "name": "", - "properties": {} - } - ] - } - } - }, - { - "id": "465796f5-9075-45f6-81b9-7fa17476392c", - "name": "sin", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [ - { - "id": "input-0bcb214f", - "name": "Input 1", - "position": { - "x": -223.0, - "y": -161.0 - }, - "properties": {} - } - ], - "outputs": [ - { - "id": "output-a55cd289", - "name": "Output 1", - "position": { - "x": 133.0, - "y": -152.0 - }, - "properties": {} - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "sin()" - }, - "properties": {}, - "library": { - "showSubtree": false - }, - "implementation": { - "kind": "graph", - "graph": { - "blocks": [], - "connections": [ - { - "id": "029ccfaa-7150-4a53-beec-286ff8e2a628", - "source": { - "interface": "input-0bcb214f" - }, - "target": { - "interface": "output-a55cd289" - }, - "name": "", - "properties": {} - } - ] - } - } - }, - { - "id": "bb0adff3-baf2-469b-9a80-f05910d0f259", - "name": "constant", - "position": { - "x": 0.0, - "y": 0.0 - }, - "rotation": 0.0, - "interface": { - "inputs": [], - "outputs": [ - { - "id": "output-1e15ff3f", - "name": "Output 1", - "position": { - "x": 71.0, - "y": -134.0 - }, - "properties": {} - } - ] - }, - "icon": { - "shape": "rectangle", - "fill": "#f4f4f4", - "border": "#303030", - "text": "C" - }, - "properties": {}, - "library": { - "showSubtree": true - }, - "implementation": { - "kind": "graph", - "graph": { - "blocks": [], - "connections": [] - } - } - } - ] -} diff --git a/BEdit/src/bedit/document/controller.py b/BEdit/src/bedit/document/controller.py index 01818c0..0d2d855 100644 --- a/BEdit/src/bedit/document/controller.py +++ b/BEdit/src/bedit/document/controller.py @@ -330,10 +330,9 @@ class DocumentController(QObject): self, component_id: str, name: str, - shape: str, - fill: str, - border: str, - text: str, + icon: Icon, + inputs: list[Port], + outputs: list[Port], show_subtree: bool, ) -> None: if self.document is None: @@ -343,15 +342,16 @@ class DocumentController(QObject): return old = { "name": component.name, - **component.icon.to_dict(), + "icon": component.icon.to_dict(), + "inputs": [port.to_dict() for port in component.inputs], + "outputs": [port.to_dict() for port in component.outputs], "show_subtree": component.show_subtree_in_library, } new = { "name": name, - "shape": shape, - "fill": fill, - "border": border, - "text": text, + "icon": icon.to_dict(), + "inputs": [port.to_dict() for port in inputs], + "outputs": [port.to_dict() for port in outputs], "show_subtree": show_subtree, } if old != new: @@ -581,12 +581,9 @@ class DocumentController(QObject): if component is None: return component.name = values["name"] - component.icon = Icon( - shape=values["shape"], - fill=values["fill"], - border=values["border"], - text=values["text"], - ) + component.icon = Icon.from_dict(values["icon"]) + component.inputs = [Port.from_dict(port) for port in values["inputs"]] + component.outputs = [Port.from_dict(port) for port in values["outputs"]] component.show_subtree_in_library = values["show_subtree"] self.documentReset.emit() if component_id == self.active_component_id: diff --git a/BEdit/src/bedit/document/model.py b/BEdit/src/bedit/document/model.py index aae44e6..bac3ec0 100644 --- a/BEdit/src/bedit/document/model.py +++ b/BEdit/src/bedit/document/model.py @@ -40,24 +40,49 @@ class Icon: fill: str = "#f4f4f4" border: str = "#303030" text: str = "" + width: float = 120.0 + height: float = 80.0 + elements: list[dict[str, Any]] = field(default_factory=list) - def to_dict(self) -> dict[str, str]: + def __post_init__(self) -> None: + if not self.elements: + self.elements = [{ + "type": "ellipse" if self.shape == "ellipse" else "rectangle", + "x": 1.0, "y": 1.0, "width": self.width - 2.0, "height": self.height - 2.0, + "fill": self.fill, "stroke": self.border, "lineWidth": 1.5, + "lineStyle": "solid", "cornerRadius": 5.0, + }] + if self.text: + self.elements.append({ + "type": "text", "x": 8.0, "y": 8.0, + "width": self.width - 16.0, "height": self.height - 16.0, + "text": self.text, "color": "#202020", "fontSize": 12.0, + }) + + def to_dict(self) -> dict[str, Any]: return { "shape": self.shape, "fill": self.fill, "border": self.border, "text": self.text, + "size": {"width": self.width, "height": self.height}, + "elements": deepcopy(self.elements), } @classmethod def from_dict(cls, data: dict[str, Any] | None) -> "Icon": data = data or {} - return cls( + size = data.get("size", {}) + icon = cls( shape=str(data.get("shape", "rectangle")), fill=str(data.get("fill", "#f4f4f4")), border=str(data.get("border", "#303030")), text=str(data.get("text", "")), + width=float(size.get("width", 120.0)), + height=float(size.get("height", 80.0)), + elements=deepcopy(data.get("elements", [])), ) + return icon @dataclass(frozen=True) @@ -318,7 +343,7 @@ def clone_component(source: Component) -> Component: y=current.y, inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs], outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.outputs], - icon=Icon(**current.icon.to_dict()), + icon=Icon.from_dict(current.icon.to_dict()), properties=deepcopy(current.properties), implementation_kind=current.implementation_kind, graph=graph if current.implementation_kind == "graph" else Graph(), diff --git a/BEdit/src/bedit/icon_editor.py b/BEdit/src/bedit/icon_editor.py new file mode 100644 index 0000000..8c9f63f --- /dev/null +++ b/BEdit/src/bedit/icon_editor.py @@ -0,0 +1,277 @@ +from copy import deepcopy + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF +from PySide6.QtWidgets import ( + QColorDialog, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGraphicsEllipseItem, + QGraphicsItem, + QGraphicsObject, + QGraphicsScene, + QGraphicsSceneContextMenuEvent, + QGraphicsView, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QMenu, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bedit.document.model import Component, Icon, Port +from bedit.icon_renderer import _pen + + +class ColorButton(QPushButton): + def __init__(self, color: str, allow_none: bool = False, parent=None) -> None: + super().__init__(parent) + self.color = color + self.allow_none = allow_none + self.clicked.connect(self.choose) + self._refresh() + + def _refresh(self) -> None: + self.setText("No fill" if self.color == "none" else self.color) + swatch = "transparent" if self.color == "none" else self.color + self.setStyleSheet(f"QPushButton {{ background: {swatch}; }}") + + def choose(self) -> None: + initial = QColor("#ffffff" if self.color == "none" else self.color) + color = QColorDialog.getColor(initial, self, "Choose colour", QColorDialog.ColorDialogOption.ShowAlphaChannel) + if color.isValid(): + self.color = color.name(QColor.NameFormat.HexArgb) if color.alpha() < 255 else color.name() + self._refresh() + + +class ShapeOptionsDialog(QDialog): + def __init__(self, element: dict, parent=None) -> None: + super().__init__(parent) + self.element = deepcopy(element) + self.setWindowTitle("Shape Options") + layout = QVBoxLayout(self) + form = QFormLayout() + self.line_style = QComboBox() + self.line_style.addItems(["solid", "dash", "dot", "dash-dot", "none"]) + self.line_style.setCurrentText(self.element.get("lineStyle", "solid")) + self.line_width = QDoubleSpinBox() + self.line_width.setRange(0.1, 20.0) + self.line_width.setValue(float(self.element.get("lineWidth", 1.5))) + self.stroke = ColorButton(self.element.get("stroke", "#303030")) + self.fill_type = QComboBox() + self.fill_type.addItems(["solid", "none"]) + fill = self.element.get("fill", "#ffffff") + self.fill_type.setCurrentText("none" if fill in {"none", "transparent", ""} else "solid") + self.fill = ColorButton("#ffffff" if fill in {"none", "transparent", ""} else fill) + self.width = QDoubleSpinBox() + self.width.setRange(1, 500) + self.width.setValue(float(self.element.get("width", 20))) + self.height = QDoubleSpinBox() + self.height.setRange(1, 500) + self.height.setValue(float(self.element.get("height", 20))) + form.addRow("Width:", self.width) + form.addRow("Height:", self.height) + form.addRow("Line style:", self.line_style) + form.addRow("Line width:", self.line_width) + form.addRow("Line colour:", self.stroke) + if self.element.get("type") != "line": + form.addRow("Fill type:", self.fill_type) + form.addRow("Fill colour:", self.fill) + self.radius = None + if self.element.get("type") == "rectangle": + self.radius = QDoubleSpinBox() + self.radius.setRange(0, 50) + self.radius.setValue(float(self.element.get("cornerRadius", 0))) + form.addRow("Corner radius:", self.radius) + self.text_edit = None + self.font_size = None + if self.element.get("type") == "text": + self.text_edit = QLineEdit(str(self.element.get("text", "Text"))) + self.font_size = QDoubleSpinBox() + self.font_size.setRange(4, 96) + self.font_size.setValue(float(self.element.get("fontSize", 12))) + form.addRow("Text:", self.text_edit) + form.addRow("Font size:", self.font_size) + layout.addLayout(form) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def accept(self) -> None: + self.element["lineStyle"] = self.line_style.currentText() + self.element["lineWidth"] = self.line_width.value() + self.element["stroke"] = self.stroke.color + self.element["width"] = self.width.value() + self.element["height"] = self.height.value() + if self.element.get("type") != "line": + self.element["fill"] = self.fill.color if self.fill_type.currentText() == "solid" else "none" + if self.radius is not None: + self.element["cornerRadius"] = self.radius.value() + if self.text_edit is not None: + self.element["text"] = self.text_edit.text() + self.element["fontSize"] = self.font_size.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) + + 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))).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(_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.ItemPositionHasChanged: + self.element["x"], self.element["y"] = value.x(), value.y() + return super().itemChange(change, value) + + 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: + dialog = ShapeOptionsDialog(self.element) + if dialog.exec() == dialog.DialogCode.Accepted: + self.prepareGeometryChange() + self.element.clear() + self.element.update(dialog.element) + 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.ItemPositionHasChanged: + self.port.properties["iconPosition"] = {"x": value.x(), "y": value.y()} + return super().itemChange(change, value) + + +class IconEditorDialog(QDialog): + def __init__(self, component: Component, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle(f"Icon Editor — {component.name}") + self.resize(850, 600) + self.icon = Icon.from_dict(component.icon.to_dict()) + self.inputs = deepcopy(component.inputs) + self.outputs = deepcopy(component.outputs) + layout = QVBoxLayout(self) + toolbar = QHBoxLayout() + toolbar.addWidget(QLabel("Add:")) + for kind in ("rectangle", "circle", "ellipse", "line", "triangle", "text"): + button = QToolButton() + button.setText(kind.title()) + button.clicked.connect(lambda _checked=False, value=kind: self.add_shape(value)) + toolbar.addWidget(button) + toolbar.addStretch() + delete = QPushButton("Delete selected") + delete.clicked.connect(self.delete_selected) + toolbar.addWidget(delete) + layout.addLayout(toolbar) + self.scene = QGraphicsScene(0, 0, self.icon.width, self.icon.height, self) + self.view = QGraphicsView(self.scene) + self.view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setBackgroundBrush(QColor("#f8fafc")) + self.view.setDragMode(QGraphicsView.DragMode.RubberBandDrag) + self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#94a3b8"), 0), QColor("#ffffff")) + layout.addWidget(self.view, 1) + layout.addWidget(QLabel("Green points are inputs; red points are outputs. Drag them to place connection anchors.")) + for element in self.icon.elements: + self.scene.addItem(ShapeItem(element)) + self._add_ports(self.inputs, "input", 0.0) + self._add_ports(self.outputs, "output", self.icon.width) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + self.view.fitInView(self.scene.sceneRect().adjusted(-10, -10, 10, 10), Qt.AspectRatioMode.KeepAspectRatio) + + 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 add_shape(self, kind: str) -> None: + count = len([item for item in self.scene.items() if isinstance(item, ShapeItem)]) + x, y = 15 + (count * 5) % 40, 15 + (count * 4) % 25 + element = {"type": kind, "x": x, "y": y, "width": 55, "height": 35, "fill": "#dbeafe", "stroke": "#303030", "lineWidth": 1.5, "lineStyle": "solid"} + if kind == "circle": + element["width"] = element["height"] = 35 + if kind == "line": + element["fill"] = "none" + if kind == "rectangle": + element["cornerRadius"] = 0 + if kind == "text": + text, accepted = QInputDialog.getText(self, "Add Text", "Text:", text="Text") + if not accepted: + return + 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() diff --git a/BEdit/src/bedit/icon_renderer.py b/BEdit/src/bedit/icon_renderer.py new file mode 100644 index 0000000..f6b070f --- /dev/null +++ b/BEdit/src/bedit/icon_renderer.py @@ -0,0 +1,66 @@ +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QPolygonF + +from bedit.document.model import Icon + + +def _pen(element: dict) -> QPen: + styles = { + "solid": Qt.PenStyle.SolidLine, + "dash": Qt.PenStyle.DashLine, + "dot": Qt.PenStyle.DotLine, + "dash-dot": Qt.PenStyle.DashDotLine, + "none": Qt.PenStyle.NoPen, + } + return QPen( + QColor(element.get("stroke", "#303030")), + float(element.get("lineWidth", 1.5)), + styles.get(element.get("lineStyle", "solid"), Qt.PenStyle.SolidLine), + ) + + +def paint_icon(painter: QPainter, icon: Icon, target: QRectF) -> None: + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.translate(target.topLeft()) + painter.scale(target.width() / icon.width, target.height() / icon.height) + for element in icon.elements: + kind = element.get("type", "rectangle") + rect = QRectF( + float(element.get("x", 0)), float(element.get("y", 0)), + float(element.get("width", 20)), float(element.get("height", 20)), + ) + painter.setPen(_pen(element)) + fill = element.get("fill", "#ffffff") + painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill)) + if kind == "rectangle": + radius = float(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(), rect.top()), rect.bottomRight(), rect.bottomLeft()])) + elif kind == "text": + painter.setPen(QColor(element.get("color", element.get("stroke", "#202020")))) + font = QFont() + font.setPointSizeF(float(element.get("fontSize", 12))) + painter.setFont(font) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, str(element.get("text", "Text"))) + painter.restore() + + +def icon_pixmap(icon: Icon, size: int = 16) -> QPixmap: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + ratio = min(size / icon.width, size / icon.height) + width, height = icon.width * ratio, icon.height * ratio + paint_icon(painter, icon, QRectF((size - width) / 2, (size - height) / 2, width, height)) + painter.end() + return pixmap + + +def library_icon(icon: Icon) -> QIcon: + return QIcon(icon_pixmap(icon, 16)) diff --git a/BEdit/src/bedit/library/tree_model.py b/BEdit/src/bedit/library/tree_model.py index 3235adb..0f116e5 100644 --- a/BEdit/src/bedit/library/tree_model.py +++ b/BEdit/src/bedit/library/tree_model.py @@ -5,6 +5,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel from bedit.document.controller import DocumentController from bedit.document.model import Component +from bedit.icon_renderer import library_icon from bedit.library.repository import LibraryRepository @@ -43,6 +44,7 @@ class LibraryTreeModel(QStandardItemModel): def _component_item(self, component: Component, current: bool = False) -> QStandardItem: item = QStandardItem(component.name) item.setEditable(False) + item.setIcon(library_icon(component.icon)) item.setData(component.to_dict(), COMPONENT_ROLE) item.setData(component.id, COMPONENT_ID_ROLE) item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE) diff --git a/BEdit/src/bedit/main_window.py b/BEdit/src/bedit/main_window.py index b33ba77..dec0c08 100644 --- a/BEdit/src/bedit/main_window.py +++ b/BEdit/src/bedit/main_window.py @@ -1,9 +1,9 @@ import json from pathlib import Path -from PySide6.QtCore import QSettings, Qt, Slot -from PySide6.QtGui import QCloseEvent -from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox +from PySide6.QtCore import QSettings, QSize, Qt, Slot +from PySide6.QtGui import QAction, QCloseEvent +from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox, QToolBar from bedit.component_options_dialog import ComponentOptionsDialog from bedit.document.controller import DocumentController @@ -27,6 +27,7 @@ class MainWindow(QMainWindow): super().__init__() self.ui = Ui_MainWindow() self.ui.setupUi(self) + self._create_camera_toolbar() self.settings = QSettings() self.libraries = LibraryRepository(self) @@ -58,11 +59,13 @@ class MainWindow(QMainWindow): def _configure_models(self) -> None: self.ui.treeView.setModel(self.library_tree_model) + self.ui.treeView.setIconSize(QSize(16, 16)) self.ui.treeView.setHeaderHidden(True) self.ui.treeView.setDragEnabled(True) self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly) self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll) self.ui.documentTreeView.setModel(self.document_tree_model) + self.ui.documentTreeView.setIconSize(QSize(16, 16)) self.ui.documentTreeView.setHeaderHidden(True) self.ui.documentTreeView.setDragEnabled(True) self.ui.documentTreeView.setDragDropMode( @@ -91,6 +94,20 @@ class MainWindow(QMainWindow): self.ui.applyJsonButton.clicked.connect(self.apply_json) self.document_controller.activeGraphChanged.connect(self._active_graph_changed) + def _create_camera_toolbar(self) -> None: + self.cameraToolbar = QToolBar("Camera", self) + self.cameraToolbar.setObjectName("cameraToolbar") + self.actionZoomIn = QAction("Zoom In", self) + self.actionZoomIn.setShortcut("Ctrl++") + self.actionZoomOut = QAction("Zoom Out", self) + self.actionZoomOut.setShortcut("Ctrl+-") + self.actionCenterView = QAction("Center", self) + self.actionCenterView.setShortcut("Ctrl+0") + self.cameraToolbar.addActions( + (self.actionZoomIn, self.actionZoomOut, self.actionCenterView) + ) + self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.cameraToolbar) + def _connect_actions(self) -> None: self.ui.actionNew.triggered.connect(self.new_document) self.ui.actionOpen.triggered.connect(self.open_document) @@ -111,6 +128,9 @@ class MainWindow(QMainWindow): self.ui.actionPaste.triggered.connect(self.ui.graphView.paste_selection) self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all) self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected) + self.actionZoomIn.triggered.connect(self.ui.graphView.zoom_in) + self.actionZoomOut.triggered.connect(self.ui.graphView.zoom_out) + self.actionCenterView.triggered.connect(self.ui.graphView.center_workspace) self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled) self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled) self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title()) @@ -128,6 +148,7 @@ class MainWindow(QMainWindow): self.ui.fileToolbar, self.ui.editToolbar, self.ui.transformToolbar, + self.cameraToolbar, ): self.ui.menuToolbars.addAction(toolbar.toggleViewAction()) @@ -425,10 +446,9 @@ class MainWindow(QMainWindow): self.document_controller.edit_component_appearance( component_id, dialog.ui.nameEdit.text().strip(), - dialog.ui.shapeCombo.currentText(), - dialog.ui.fillEdit.text(), - dialog.ui.borderEdit.text(), - dialog.ui.iconTextEdit.text(), + dialog.edited_icon, + dialog.edited_inputs, + dialog.edited_outputs, dialog.ui.showSubtreeCheckBox.isChecked(), ) diff --git a/BEdit/src/bedit/workspace/view.py b/BEdit/src/bedit/workspace/view.py index 53b8d0b..6eab230 100644 --- a/BEdit/src/bedit/workspace/view.py +++ b/BEdit/src/bedit/workspace/view.py @@ -6,6 +6,7 @@ from PySide6.QtGui import ( QDragEnterEvent, QDropEvent, QMouseEvent, + QWheelEvent, QPainter, QPainterPath, QPen, @@ -29,6 +30,7 @@ from PySide6.QtWidgets import ( from bedit.document.controller import DocumentController from bedit.document.model import Component, Connection, Endpoint, Port from bedit.library.tree_model import COMPONENT_MIME_TYPE +from bedit.icon_renderer import paint_icon SELECTION_MIME_TYPE = "application/x-bedit-selection" @@ -71,7 +73,11 @@ class ComponentGraphicsItem(QGraphicsObject): for index, port in enumerate(ports, start=1): endpoint = Endpoint(block=self.component_id, port=port.id) item = ConnectionPortItem(endpoint, role, port.name, self) - item.setPos(x, spacing * index) + position = port.properties.get("iconPosition", {}) + item.setPos( + float(position.get("x", x)) * self.WIDTH / self.component.icon.width, + float(position.get("y", spacing * index)) * self.HEIGHT / self.component.icon.height, + ) result[port.id] = item return result @@ -85,20 +91,11 @@ class ComponentGraphicsItem(QGraphicsObject): widget: QWidget | None = None, ) -> None: del option, widget - icon = self.component.icon - fill = QColor("#dbeafe") if self.isSelected() else QColor(icon.fill) - painter.setBrush(fill) - painter.setPen(QPen(QColor(icon.border), 1.5)) - if icon.shape == "ellipse": - painter.drawEllipse(self.boundingRect()) - else: - painter.drawRoundedRect(self.boundingRect(), 5, 5) - painter.setPen(QColor("#202020")) - painter.drawText( - self.boundingRect(), - Qt.AlignmentFlag.AlignCenter, - icon.text or self.component.name, - ) + paint_icon(painter, self.component.icon, self.boundingRect()) + if self.isSelected(): + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor("#2563eb"), 2, Qt.PenStyle.DashLine)) + painter.drawRect(self.boundingRect()) def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 self.controller.activate_component(self.component_id) @@ -386,6 +383,35 @@ class GraphWorkspaceView(QGraphicsView): self.setRenderHint(QPainter.RenderHint.Antialiasing) self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) self.setBackgroundBrush(QColor("#9a9a9a")) + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) + + def _zoom(self, factor: float) -> None: + current = self.transform().m11() + target = current * factor + if 0.1 <= target <= 8.0: + self.scale(factor, factor) + + def zoom_in(self) -> None: + self._zoom(1.2) + + def zoom_out(self) -> None: + self._zoom(1 / 1.2) + + def center_workspace(self) -> None: + scene = self.scene() + if scene is None: + return + bounds = scene.itemsBoundingRect() + if bounds.isEmpty(): + self.resetTransform() + self.centerOn(0, 0) + else: + self.fitInView(bounds.adjusted(-80, -80, 80, 80), Qt.AspectRatioMode.KeepAspectRatio) + + def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802 + self._zoom(1.2 if event.angleDelta().y() > 0 else 1 / 1.2) + event.accept() def set_model(self, controller: DocumentController) -> None: self.controller = controller