Implemented the vector icon editor, library previews, and workspace camera controls.

Key changes:
Component Options now has an “Edit Icon…” button instead of direct shape/color fields.
Icon editor supports:Rectangles with configurable corner radius
Circles and ellipses
Lines
Triangles
Text
Movable/selectable shapes
Shape context menus
Line style and width
Solid or transparent fill
Stroke and fill colours through Qt’s colour picker
Editable shape dimensions
Deleting selected shapes

Input anchors are green and output anchors red; both can be dragged around the icon.
Icons use a 120×80 vector coordinate system and are stored as editable elements in document JSON.
Components render their vector icons directly in the workspace.
Library and document trees display 16×16 icon previews.
Added a Camera toolbar:Zoom In
Zoom Out
Center

Mouse-wheel zoom works directly over the workspace.
Icon changes and port positions support undo/redo.
This commit is contained in:
2026-07-19 21:46:44 +02:00
parent 76bd313f79
commit 09824195c9
11 changed files with 498 additions and 464 deletions

View File

@@ -134,14 +134,22 @@ Every component owns its ports, declarative icon, properties, and child graph:
"name": "My Component", "name": "My Component",
"position": {"x": 0, "y": 0}, "position": {"x": 0, "y": 0},
"interface": { "interface": {
"inputs": [{"id": "in", "name": "Input"}], "inputs": [{"id": "in", "name": "Input", "properties": {
"outputs": [{"id": "out", "name": "Output"}] "iconPosition": {"x": 0, "y": 40}
}}],
"outputs": [{"id": "out", "name": "Output", "properties": {
"iconPosition": {"x": 120, "y": 40}
}}]
}, },
"icon": { "icon": {
"shape": "rectangle", "size": {"width": 120, "height": 80},
"fill": "#dbeafe", "elements": [{
"border": "#245c9c", "type": "rectangle", "x": 1, "y": 1,
"text": "Component" "width": 118, "height": 78,
"cornerRadius": 5,
"fill": "#dbeafe", "stroke": "#245c9c",
"lineWidth": 1.5, "lineStyle": "solid"
}]
}, },
"properties": {}, "properties": {},
"implementation": { "implementation": {
@@ -157,8 +165,10 @@ Every component owns its ports, declarative icon, properties, and child graph:
Graph components use `"implementation": {"kind": "graph", "graph": ...}`; Graph components use `"implementation": {"kind": "graph", "graph": ...}`;
text components use `"implementation": {"kind": "text", "source": ...}` and text components use `"implementation": {"kind": "text", "source": ...}` and
never own a graph. Supported icon shapes are currently `rectangle` and never own a graph. Vector icons can contain rectangles, circles, ellipses,
`ellipse`. The recursive model is under `src/bedit/document/`, library loading 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 and the live Current Document tree are under `src/bedit/library/`, and graphics
are isolated under `src/bedit/workspace/`. are isolated under `src/bedit/workspace/`.

View File

@@ -1,7 +1,7 @@
from PySide6.QtGui import QColor from PySide6.QtWidgets import QDialog, QMessageBox, QPushButton
from PySide6.QtWidgets import QDialog, QMessageBox
from bedit.document.model import Component from bedit.document.model import Component
from bedit.icon_editor import IconEditorDialog
from bedit.ui_component_options_dialog import Ui_ComponentOptionsDialog from bedit.ui_component_options_dialog import Ui_ComponentOptionsDialog
@@ -10,22 +10,36 @@ class ComponentOptionsDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.ui = Ui_ComponentOptionsDialog() self.ui = Ui_ComponentOptionsDialog()
self.ui.setupUi(self) 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.nameEdit.setText(component.name)
self.ui.shapeCombo.setCurrentText(component.icon.shape) for widget in (
self.ui.iconTextEdit.setText(component.icon.text) self.ui.shapeLabel, self.ui.shapeCombo, self.ui.iconTextLabel,
self.ui.fillEdit.setText(component.icon.fill) self.ui.iconTextEdit, self.ui.fillLabel, self.ui.fillEdit,
self.ui.borderEdit.setText(component.icon.border) 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) 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: def accept(self) -> None:
if not self.ui.nameEdit.text().strip(): if not self.ui.nameEdit.text().strip():
QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.") QMessageBox.warning(self, "Invalid name", "The component name cannot be empty.")
return 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() super().accept()

View File

@@ -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": []}}
}
]
}

View File

@@ -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": []
}
}
}
]
}

View File

@@ -330,10 +330,9 @@ class DocumentController(QObject):
self, self,
component_id: str, component_id: str,
name: str, name: str,
shape: str, icon: Icon,
fill: str, inputs: list[Port],
border: str, outputs: list[Port],
text: str,
show_subtree: bool, show_subtree: bool,
) -> None: ) -> None:
if self.document is None: if self.document is None:
@@ -343,15 +342,16 @@ class DocumentController(QObject):
return return
old = { old = {
"name": component.name, "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, "show_subtree": component.show_subtree_in_library,
} }
new = { new = {
"name": name, "name": name,
"shape": shape, "icon": icon.to_dict(),
"fill": fill, "inputs": [port.to_dict() for port in inputs],
"border": border, "outputs": [port.to_dict() for port in outputs],
"text": text,
"show_subtree": show_subtree, "show_subtree": show_subtree,
} }
if old != new: if old != new:
@@ -581,12 +581,9 @@ class DocumentController(QObject):
if component is None: if component is None:
return return
component.name = values["name"] component.name = values["name"]
component.icon = Icon( component.icon = Icon.from_dict(values["icon"])
shape=values["shape"], component.inputs = [Port.from_dict(port) for port in values["inputs"]]
fill=values["fill"], component.outputs = [Port.from_dict(port) for port in values["outputs"]]
border=values["border"],
text=values["text"],
)
component.show_subtree_in_library = values["show_subtree"] component.show_subtree_in_library = values["show_subtree"]
self.documentReset.emit() self.documentReset.emit()
if component_id == self.active_component_id: if component_id == self.active_component_id:

View File

@@ -40,24 +40,49 @@ class Icon:
fill: str = "#f4f4f4" fill: str = "#f4f4f4"
border: str = "#303030" border: str = "#303030"
text: str = "" 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 { return {
"shape": self.shape, "shape": self.shape,
"fill": self.fill, "fill": self.fill,
"border": self.border, "border": self.border,
"text": self.text, "text": self.text,
"size": {"width": self.width, "height": self.height},
"elements": deepcopy(self.elements),
} }
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Icon": def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
data = data or {} data = data or {}
return cls( size = data.get("size", {})
icon = cls(
shape=str(data.get("shape", "rectangle")), shape=str(data.get("shape", "rectangle")),
fill=str(data.get("fill", "#f4f4f4")), fill=str(data.get("fill", "#f4f4f4")),
border=str(data.get("border", "#303030")), border=str(data.get("border", "#303030")),
text=str(data.get("text", "")), 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) @dataclass(frozen=True)
@@ -318,7 +343,7 @@ def clone_component(source: Component) -> Component:
y=current.y, y=current.y,
inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs], 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], 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), properties=deepcopy(current.properties),
implementation_kind=current.implementation_kind, implementation_kind=current.implementation_kind,
graph=graph if current.implementation_kind == "graph" else Graph(), graph=graph if current.implementation_kind == "graph" else Graph(),

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ from PySide6.QtGui import QStandardItem, QStandardItemModel
from bedit.document.controller import DocumentController from bedit.document.controller import DocumentController
from bedit.document.model import Component from bedit.document.model import Component
from bedit.icon_renderer import library_icon
from bedit.library.repository import LibraryRepository from bedit.library.repository import LibraryRepository
@@ -43,6 +44,7 @@ class LibraryTreeModel(QStandardItemModel):
def _component_item(self, component: Component, current: bool = False) -> QStandardItem: def _component_item(self, component: Component, current: bool = False) -> QStandardItem:
item = QStandardItem(component.name) item = QStandardItem(component.name)
item.setEditable(False) item.setEditable(False)
item.setIcon(library_icon(component.icon))
item.setData(component.to_dict(), COMPONENT_ROLE) item.setData(component.to_dict(), COMPONENT_ROLE)
item.setData(component.id, COMPONENT_ID_ROLE) item.setData(component.id, COMPONENT_ID_ROLE)
item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE) item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE)

View File

@@ -1,9 +1,9 @@
import json import json
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QSettings, Qt, Slot from PySide6.QtCore import QSettings, QSize, Qt, Slot
from PySide6.QtGui import QCloseEvent from PySide6.QtGui import QAction, QCloseEvent
from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox, QToolBar
from bedit.component_options_dialog import ComponentOptionsDialog from bedit.component_options_dialog import ComponentOptionsDialog
from bedit.document.controller import DocumentController from bedit.document.controller import DocumentController
@@ -27,6 +27,7 @@ class MainWindow(QMainWindow):
super().__init__() super().__init__()
self.ui = Ui_MainWindow() self.ui = Ui_MainWindow()
self.ui.setupUi(self) self.ui.setupUi(self)
self._create_camera_toolbar()
self.settings = QSettings() self.settings = QSettings()
self.libraries = LibraryRepository(self) self.libraries = LibraryRepository(self)
@@ -58,11 +59,13 @@ class MainWindow(QMainWindow):
def _configure_models(self) -> None: def _configure_models(self) -> None:
self.ui.treeView.setModel(self.library_tree_model) self.ui.treeView.setModel(self.library_tree_model)
self.ui.treeView.setIconSize(QSize(16, 16))
self.ui.treeView.setHeaderHidden(True) self.ui.treeView.setHeaderHidden(True)
self.ui.treeView.setDragEnabled(True) self.ui.treeView.setDragEnabled(True)
self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly) self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll) self.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll)
self.ui.documentTreeView.setModel(self.document_tree_model) self.ui.documentTreeView.setModel(self.document_tree_model)
self.ui.documentTreeView.setIconSize(QSize(16, 16))
self.ui.documentTreeView.setHeaderHidden(True) self.ui.documentTreeView.setHeaderHidden(True)
self.ui.documentTreeView.setDragEnabled(True) self.ui.documentTreeView.setDragEnabled(True)
self.ui.documentTreeView.setDragDropMode( self.ui.documentTreeView.setDragDropMode(
@@ -91,6 +94,20 @@ class MainWindow(QMainWindow):
self.ui.applyJsonButton.clicked.connect(self.apply_json) self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed) 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: def _connect_actions(self) -> None:
self.ui.actionNew.triggered.connect(self.new_document) self.ui.actionNew.triggered.connect(self.new_document)
self.ui.actionOpen.triggered.connect(self.open_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.actionPaste.triggered.connect(self.ui.graphView.paste_selection)
self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all) self.ui.actionSelectAll.triggered.connect(self.ui.graphView.select_all)
self.ui.actionRotateClockwise.triggered.connect(self.ui.graphView.rotate_selected) 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.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled) self.document_controller.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled)
self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title()) self.document_controller.modifiedChanged.connect(lambda _modified: self._update_title())
@@ -128,6 +148,7 @@ class MainWindow(QMainWindow):
self.ui.fileToolbar, self.ui.fileToolbar,
self.ui.editToolbar, self.ui.editToolbar,
self.ui.transformToolbar, self.ui.transformToolbar,
self.cameraToolbar,
): ):
self.ui.menuToolbars.addAction(toolbar.toggleViewAction()) self.ui.menuToolbars.addAction(toolbar.toggleViewAction())
@@ -425,10 +446,9 @@ class MainWindow(QMainWindow):
self.document_controller.edit_component_appearance( self.document_controller.edit_component_appearance(
component_id, component_id,
dialog.ui.nameEdit.text().strip(), dialog.ui.nameEdit.text().strip(),
dialog.ui.shapeCombo.currentText(), dialog.edited_icon,
dialog.ui.fillEdit.text(), dialog.edited_inputs,
dialog.ui.borderEdit.text(), dialog.edited_outputs,
dialog.ui.iconTextEdit.text(),
dialog.ui.showSubtreeCheckBox.isChecked(), dialog.ui.showSubtreeCheckBox.isChecked(),
) )

View File

@@ -6,6 +6,7 @@ from PySide6.QtGui import (
QDragEnterEvent, QDragEnterEvent,
QDropEvent, QDropEvent,
QMouseEvent, QMouseEvent,
QWheelEvent,
QPainter, QPainter,
QPainterPath, QPainterPath,
QPen, QPen,
@@ -29,6 +30,7 @@ from PySide6.QtWidgets import (
from bedit.document.controller import DocumentController from bedit.document.controller import DocumentController
from bedit.document.model import Component, Connection, Endpoint, Port from bedit.document.model import Component, Connection, Endpoint, Port
from bedit.library.tree_model import COMPONENT_MIME_TYPE from bedit.library.tree_model import COMPONENT_MIME_TYPE
from bedit.icon_renderer import paint_icon
SELECTION_MIME_TYPE = "application/x-bedit-selection" SELECTION_MIME_TYPE = "application/x-bedit-selection"
@@ -71,7 +73,11 @@ class ComponentGraphicsItem(QGraphicsObject):
for index, port in enumerate(ports, start=1): for index, port in enumerate(ports, start=1):
endpoint = Endpoint(block=self.component_id, port=port.id) endpoint = Endpoint(block=self.component_id, port=port.id)
item = ConnectionPortItem(endpoint, role, port.name, self) 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 result[port.id] = item
return result return result
@@ -85,20 +91,11 @@ class ComponentGraphicsItem(QGraphicsObject):
widget: QWidget | None = None, widget: QWidget | None = None,
) -> None: ) -> None:
del option, widget del option, widget
icon = self.component.icon paint_icon(painter, self.component.icon, self.boundingRect())
fill = QColor("#dbeafe") if self.isSelected() else QColor(icon.fill) if self.isSelected():
painter.setBrush(fill) painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(icon.border), 1.5)) painter.setPen(QPen(QColor("#2563eb"), 2, Qt.PenStyle.DashLine))
if icon.shape == "ellipse": painter.drawRect(self.boundingRect())
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,
)
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
self.controller.activate_component(self.component_id) self.controller.activate_component(self.component_id)
@@ -386,6 +383,35 @@ class GraphWorkspaceView(QGraphicsView):
self.setRenderHint(QPainter.RenderHint.Antialiasing) self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.setBackgroundBrush(QColor("#9a9a9a")) 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: def set_model(self, controller: DocumentController) -> None:
self.controller = controller self.controller = controller