Compare commits
2 Commits
09824195c9
...
8df5c2a8f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 8df5c2a8f9 | |||
| dbca41640f |
@@ -134,18 +134,18 @@ 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", "properties": {
|
||||
"inputs": [{"id": "in", "name": "Input", "type": "signal", "properties": {
|
||||
"iconPosition": {"x": 0, "y": 40}
|
||||
}}],
|
||||
"outputs": [{"id": "out", "name": "Output", "properties": {
|
||||
"iconPosition": {"x": 120, "y": 40}
|
||||
"outputs": [{"id": "out", "name": "Output", "type": "signal", "properties": {
|
||||
"iconPosition": {"x": 128, "y": 64}
|
||||
}}]
|
||||
},
|
||||
"icon": {
|
||||
"size": {"width": 120, "height": 80},
|
||||
"size": {"width": 128, "height": 128},
|
||||
"elements": [{
|
||||
"type": "rectangle", "x": 1, "y": 1,
|
||||
"width": 118, "height": 78,
|
||||
"type": "rectangle", "x": 32, "y": 32,
|
||||
"width": 64, "height": 64,
|
||||
"cornerRadius": 5,
|
||||
"fill": "#dbeafe", "stroke": "#245c9c",
|
||||
"lineWidth": 1.5, "lineStyle": "solid"
|
||||
@@ -168,6 +168,8 @@ text components use `"implementation": {"kind": "text", "source": ...}` and
|
||||
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
|
||||
port type registry controls which types may connect (currently `signal` only).
|
||||
Graph and icon grid sizes are configured independently in Settings. 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/`.
|
||||
|
||||
162
BEdit/data/libraries/test.bedit.json
Normal file
162
BEdit/data/libraries/test.bedit.json
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"format": "bedit-document",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"name": "Untitled"
|
||||
},
|
||||
"roots": [
|
||||
{
|
||||
"id": "5ee742db-b25c-4d86-b4ca-cc0e61c4002a",
|
||||
"name": "New Graph Block 1",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"interface": {
|
||||
"inputs": [],
|
||||
"outputs": []
|
||||
},
|
||||
"icon": {
|
||||
"shape": "rectangle",
|
||||
"fill": "#f4f4f4",
|
||||
"border": "#303030",
|
||||
"text": "Graph",
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 128.0
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"width": 126.0,
|
||||
"height": 126.0,
|
||||
"fill": "#f4f4f4",
|
||||
"stroke": "#303030",
|
||||
"lineWidth": 1.5,
|
||||
"lineStyle": "solid",
|
||||
"cornerRadius": 5.0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"x": 8.0,
|
||||
"y": 8.0,
|
||||
"width": 112.0,
|
||||
"height": 112.0,
|
||||
"text": "Graph",
|
||||
"color": "#202020",
|
||||
"fontSize": 12.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"properties": {},
|
||||
"library": {
|
||||
"showSubtree": true
|
||||
},
|
||||
"implementation": {
|
||||
"kind": "graph",
|
||||
"graph": {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "89944479-e73e-446a-8d58-ebf35ac4144b",
|
||||
"name": "New Graph Block 1",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -96.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"interface": {
|
||||
"inputs": [
|
||||
{
|
||||
"id": "port-e5cb81a5",
|
||||
"name": "Port 1",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"properties": {
|
||||
"iconPosition": {
|
||||
"x": 32.0,
|
||||
"y": 64.0
|
||||
}
|
||||
},
|
||||
"type": "signal"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "port-eb0034af",
|
||||
"name": "Port 2",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"properties": {
|
||||
"iconPosition": {
|
||||
"x": 96.0,
|
||||
"y": 64.0
|
||||
}
|
||||
},
|
||||
"type": "signal"
|
||||
}
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"shape": "rectangle",
|
||||
"fill": "#f4f4f4",
|
||||
"border": "#303030",
|
||||
"text": "Graph",
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 128.0
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"x": 32.0,
|
||||
"y": 32.0,
|
||||
"width": 64.0,
|
||||
"height": 64.0,
|
||||
"fill": "#f4f4f4",
|
||||
"stroke": "#303030",
|
||||
"lineWidth": 1.5,
|
||||
"lineStyle": "solid",
|
||||
"cornerRadius": 5.0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"x": 40.0,
|
||||
"y": 40.0,
|
||||
"width": 48.0,
|
||||
"height": 48.0,
|
||||
"text": "Graph",
|
||||
"color": "#303030",
|
||||
"fontSize": 12.0,
|
||||
"lineStyle": "solid",
|
||||
"lineWidth": 1.5,
|
||||
"stroke": "#303030",
|
||||
"fill": "#ffffff"
|
||||
}
|
||||
]
|
||||
},
|
||||
"properties": {},
|
||||
"library": {
|
||||
"showSubtree": true
|
||||
},
|
||||
"implementation": {
|
||||
"kind": "graph",
|
||||
"graph": {
|
||||
"blocks": [],
|
||||
"connections": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,6 +29,7 @@ from bedit.document.model import (
|
||||
Port,
|
||||
clone_component,
|
||||
)
|
||||
from bedit.document.port_types import PortTypeRegistry
|
||||
from bedit.document.serializer import JsonDocumentSerializer
|
||||
|
||||
|
||||
@@ -227,12 +228,33 @@ class DocumentController(QObject):
|
||||
def connect(self, source: Endpoint, target: Endpoint) -> str:
|
||||
if self.active_component_id is None:
|
||||
raise ValueError("There is no active graph")
|
||||
source_port = self._port_for_endpoint(source, "source")
|
||||
target_port = self._port_for_endpoint(target, "target")
|
||||
if source_port is None or target_port is None:
|
||||
raise ValueError("A connection endpoint no longer exists")
|
||||
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
|
||||
raise ValueError(
|
||||
f"Cannot connect {source_port.type!r} to {target_port.type!r}"
|
||||
)
|
||||
connection = Connection(str(uuid4()), source, target)
|
||||
self.undo_stack.push(
|
||||
AddConnectionCommand(self, self.active_component_id, connection)
|
||||
)
|
||||
return connection.id
|
||||
|
||||
def _port_for_endpoint(self, endpoint: Endpoint, role: str) -> Port | None:
|
||||
owner = self.active_component
|
||||
if owner is None:
|
||||
return None
|
||||
if endpoint.interface is not None:
|
||||
ports = owner.inputs if role == "source" else owner.outputs
|
||||
else:
|
||||
component = owner.graph.blocks.get(endpoint.block or "")
|
||||
if component is None:
|
||||
return None
|
||||
ports = component.outputs if role == "source" else component.inputs
|
||||
return next((port for port in ports if port.id == (endpoint.interface or endpoint.port)), None)
|
||||
|
||||
def add_interface_port(self, direction: str, position: QPointF) -> str:
|
||||
component = self.active_component
|
||||
if component is None or component.implementation_kind != "graph":
|
||||
@@ -354,6 +376,55 @@ class DocumentController(QObject):
|
||||
"outputs": [port.to_dict() for port in outputs],
|
||||
"show_subtree": show_subtree,
|
||||
}
|
||||
if old != new:
|
||||
candidate = deepcopy(self.document)
|
||||
candidate_component = candidate.find_component(component_id)
|
||||
candidate_component.name = name
|
||||
candidate_component.icon = Icon.from_dict(icon.to_dict())
|
||||
candidate_component.inputs = deepcopy(inputs)
|
||||
candidate_component.outputs = deepcopy(outputs)
|
||||
candidate.validate()
|
||||
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
|
||||
|
||||
def edit_component_ports(
|
||||
self, component_id: str, inputs: list[Port], outputs: list[Port]
|
||||
) -> None:
|
||||
if self.document is None:
|
||||
return
|
||||
component = self.document.find_component(component_id)
|
||||
if component is None:
|
||||
return
|
||||
input_ids = {port.id for port in inputs}
|
||||
output_ids = {port.id for port in outputs}
|
||||
parent = self.document.find_parent(component_id)
|
||||
if parent is not None:
|
||||
for connection in parent.graph.connections.values():
|
||||
if connection.target.block == component_id and connection.target.port not in input_ids:
|
||||
raise ValueError("An input cannot be removed or reoriented while connected")
|
||||
if connection.source.block == component_id and connection.source.port not in output_ids:
|
||||
raise ValueError("An output cannot be removed or reoriented while connected")
|
||||
for connection in component.graph.connections.values():
|
||||
if connection.source.interface and connection.source.interface not in input_ids:
|
||||
raise ValueError("An interface input cannot be removed while connected")
|
||||
if connection.target.interface and connection.target.interface not in output_ids:
|
||||
raise ValueError("An interface output cannot be removed while connected")
|
||||
candidate = deepcopy(self.document)
|
||||
candidate_component = candidate.find_component(component_id)
|
||||
candidate_component.inputs = deepcopy(inputs)
|
||||
candidate_component.outputs = deepcopy(outputs)
|
||||
candidate.validate()
|
||||
old = {
|
||||
"name": component.name,
|
||||
"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 = {
|
||||
**old,
|
||||
"inputs": [port.to_dict() for port in inputs],
|
||||
"outputs": [port.to_dict() for port in outputs],
|
||||
}
|
||||
if old != new:
|
||||
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from bedit.document.port_types import PortTypeRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
class Port:
|
||||
@@ -13,6 +15,7 @@ class Port:
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
properties: dict[str, Any] = field(default_factory=dict)
|
||||
type: str = "signal"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -20,6 +23,7 @@ class Port:
|
||||
"name": self.name,
|
||||
"position": {"x": self.x, "y": self.y},
|
||||
"properties": self.properties,
|
||||
"type": self.type,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -31,6 +35,7 @@ class Port:
|
||||
x=float(position.get("x", 0.0)),
|
||||
y=float(position.get("y", 0.0)),
|
||||
properties=dict(data.get("properties", {})),
|
||||
type=str(data.get("type", "signal")),
|
||||
)
|
||||
|
||||
|
||||
@@ -40,22 +45,22 @@ class Icon:
|
||||
fill: str = "#f4f4f4"
|
||||
border: str = "#303030"
|
||||
text: str = ""
|
||||
width: float = 120.0
|
||||
height: float = 80.0
|
||||
width: float = 128.0
|
||||
height: float = 128.0
|
||||
elements: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
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,
|
||||
"x": 32.0, "y": 32.0, "width": 64.0, "height": 64.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,
|
||||
"type": "text", "x": 40.0, "y": 40.0,
|
||||
"width": 48.0, "height": 48.0,
|
||||
"text": self.text, "color": "#202020", "fontSize": 12.0,
|
||||
})
|
||||
|
||||
@@ -72,14 +77,13 @@ class Icon:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
|
||||
data = data or {}
|
||||
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)),
|
||||
width=128.0,
|
||||
height=128.0,
|
||||
elements=deepcopy(data.get("elements", [])),
|
||||
)
|
||||
return icon
|
||||
@@ -293,21 +297,31 @@ class GraphDocument:
|
||||
def _validate_graph(owner: Component) -> None:
|
||||
input_ids = {port.id for port in owner.inputs}
|
||||
output_ids = {port.id for port in owner.outputs}
|
||||
if len(input_ids) != len(owner.inputs) or len(output_ids) != len(owner.outputs):
|
||||
raise ValueError(f"Component {owner.name} contains duplicate port IDs")
|
||||
for port in (*owner.inputs, *owner.outputs):
|
||||
PortTypeRegistry.get(port.type)
|
||||
for connection in owner.graph.connections.values():
|
||||
if connection.source.interface is not None:
|
||||
if connection.source.interface not in input_ids:
|
||||
raise ValueError(f"Connection {connection.id} uses an unknown interface input")
|
||||
source_port = next(p for p in owner.inputs if p.id == connection.source.interface)
|
||||
else:
|
||||
source = owner.graph.blocks.get(connection.source.block or "")
|
||||
if source is None or connection.source.port not in {p.id for p in source.outputs}:
|
||||
raise ValueError(f"Connection {connection.id} uses an unknown block output")
|
||||
source_port = next(p for p in source.outputs if p.id == connection.source.port)
|
||||
if connection.target.interface is not None:
|
||||
if connection.target.interface not in output_ids:
|
||||
raise ValueError(f"Connection {connection.id} uses an unknown interface output")
|
||||
target_port = next(p for p in owner.outputs if p.id == connection.target.interface)
|
||||
else:
|
||||
target = owner.graph.blocks.get(connection.target.block or "")
|
||||
if target is None or connection.target.port not in {p.id for p in target.inputs}:
|
||||
raise ValueError(f"Connection {connection.id} uses an unknown block input")
|
||||
target_port = next(p for p in target.inputs if p.id == connection.target.port)
|
||||
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
|
||||
raise ValueError(f"Connection {connection.id} joins incompatible port types")
|
||||
|
||||
|
||||
def clone_component(source: Component) -> Component:
|
||||
@@ -341,8 +355,8 @@ def clone_component(source: Component) -> Component:
|
||||
name=current.name,
|
||||
x=current.x,
|
||||
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],
|
||||
inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) for port in current.inputs],
|
||||
outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) for port in current.outputs],
|
||||
icon=Icon.from_dict(current.icon.to_dict()),
|
||||
properties=deepcopy(current.properties),
|
||||
implementation_kind=current.implementation_kind,
|
||||
|
||||
32
BEdit/src/bedit/document/port_types.py
Normal file
32
BEdit/src/bedit/document/port_types.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortType:
|
||||
id: str
|
||||
display_name: str
|
||||
description: str = ""
|
||||
|
||||
def accepts(self, other: "PortType") -> bool:
|
||||
return self.id == other.id
|
||||
|
||||
|
||||
class PortTypeRegistry:
|
||||
_types = {
|
||||
"signal": PortType("signal", "Signal", "A scalar signal connection"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> tuple[PortType, ...]:
|
||||
return tuple(cls._types.values())
|
||||
|
||||
@classmethod
|
||||
def get(cls, type_id: str) -> PortType:
|
||||
try:
|
||||
return cls._types[type_id]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"Unknown port type: {type_id}") from error
|
||||
|
||||
@classmethod
|
||||
def compatible(cls, first: str, second: str) -> bool:
|
||||
return cls.get(first).accepts(cls.get(second))
|
||||
@@ -1,6 +1,6 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
from PySide6.QtCore import QPointF, QRectF, QSettings, Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF
|
||||
from PySide6.QtWidgets import (
|
||||
QColorDialog,
|
||||
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
||||
QGraphicsObject,
|
||||
QGraphicsScene,
|
||||
QGraphicsSceneContextMenuEvent,
|
||||
QGraphicsSceneMouseEvent,
|
||||
QGraphicsView,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
@@ -30,6 +31,60 @@ from bedit.document.model import Component, Icon, Port
|
||||
from bedit.icon_renderer import _pen
|
||||
|
||||
|
||||
def _icon_grid_size() -> int:
|
||||
return QSettings().value("grid/iconSize", 8, type=int)
|
||||
|
||||
|
||||
def _snap(value: float) -> float:
|
||||
grid = _icon_grid_size()
|
||||
return round(value / grid) * grid
|
||||
|
||||
|
||||
class IconEditorView(QGraphicsView):
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
||||
painter.fillRect(rect, QColor("#ffffff"))
|
||||
grid = _icon_grid_size()
|
||||
painter.setPen(QPen(QColor("#dbeafe"), 0))
|
||||
left = int(rect.left()) - int(rect.left()) % grid
|
||||
top = int(rect.top()) - int(rect.top()) % grid
|
||||
for x in range(left, int(rect.right()) + grid, grid):
|
||||
painter.drawLine(x, rect.top(), x, rect.bottom())
|
||||
for y in range(top, int(rect.bottom()) + grid, grid):
|
||||
painter.drawLine(rect.left(), y, rect.right(), y)
|
||||
|
||||
|
||||
class ResizeHandle(QGraphicsEllipseItem):
|
||||
def __init__(self, owner: "ShapeItem") -> None:
|
||||
super().__init__(-4, -4, 8, 8, owner)
|
||||
self.owner = owner
|
||||
self.setBrush(QColor("#ffffff"))
|
||||
self.setPen(QPen(QColor("#2563eb"), 1.5))
|
||||
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
self.setZValue(20)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
maximum = (
|
||||
self.owner.scene().sceneRect().bottomRight() - self.owner.pos()
|
||||
if self.owner.scene()
|
||||
else QPointF(128, 128)
|
||||
)
|
||||
value = QPointF(
|
||||
min(maximum.x(), max(_icon_grid_size(), _snap(value.x()))),
|
||||
min(maximum.y(), max(_icon_grid_size(), _snap(value.y()))),
|
||||
)
|
||||
if self.owner.element.get("type") == "circle":
|
||||
side = min(maximum.x(), maximum.y(), max(value.x(), value.y()))
|
||||
value = QPointF(side, side)
|
||||
self.owner.prepareGeometryChange()
|
||||
self.owner.element["width"] = value.x()
|
||||
self.owner.element["height"] = value.y()
|
||||
self.owner.update()
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
def __init__(self, color: str, allow_none: bool = False, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -128,6 +183,9 @@ class ShapeItem(QGraphicsObject):
|
||||
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)
|
||||
self.resize_handle = ResizeHandle(self)
|
||||
self.resize_handle.setPos(float(element.get("width", 20)), float(element.get("height", 20)))
|
||||
self.resize_handle.hide()
|
||||
|
||||
def boundingRect(self) -> QRectF: # noqa: N802
|
||||
margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
|
||||
@@ -161,10 +219,38 @@ class ShapeItem(QGraphicsObject):
|
||||
painter.drawRect(rect)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
|
||||
value = QPointF(
|
||||
max(
|
||||
bounds.left(),
|
||||
min(
|
||||
bounds.right() - float(self.element.get("width", 20)),
|
||||
_snap(value.x()),
|
||||
),
|
||||
),
|
||||
max(
|
||||
bounds.top(),
|
||||
min(
|
||||
bounds.bottom() - float(self.element.get("height", 20)),
|
||||
_snap(value.y()),
|
||||
),
|
||||
),
|
||||
)
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
self.element["x"], self.element["y"] = value.x(), value.y()
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
|
||||
self.resize_handle.setVisible(bool(value))
|
||||
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(
|
||||
max(bounds.left(), min(bounds.right() - float(self.element.get("width", 20)), _snap(self.pos().x()))),
|
||||
max(bounds.top(), min(bounds.bottom() - float(self.element.get("height", 20)), _snap(self.pos().y()))),
|
||||
)
|
||||
|
||||
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
||||
menu = QMenu()
|
||||
options = menu.addAction("Shape Options…")
|
||||
@@ -176,6 +262,10 @@ class ShapeItem(QGraphicsObject):
|
||||
self.prepareGeometryChange()
|
||||
self.element.clear()
|
||||
self.element.update(dialog.element)
|
||||
self.resize_handle.setPos(
|
||||
float(self.element.get("width", 20)),
|
||||
float(self.element.get("height", 20)),
|
||||
)
|
||||
self.update()
|
||||
elif chosen is delete and self.scene() is not None:
|
||||
self.scene().removeItem(self)
|
||||
@@ -195,10 +285,24 @@ class PortHandle(QGraphicsEllipseItem):
|
||||
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
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 IconEditorDialog(QDialog):
|
||||
def __init__(self, component: Component, parent=None) -> None:
|
||||
@@ -222,11 +326,10 @@ class IconEditorDialog(QDialog):
|
||||
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 = IconEditorView(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"))
|
||||
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
|
||||
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:
|
||||
|
||||
@@ -4,6 +4,22 @@ from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QPolygo
|
||||
from bedit.document.model import Icon
|
||||
|
||||
|
||||
def icon_bounds(icon: Icon) -> QRectF:
|
||||
"""Return the automatic hitbox of all visible vector elements."""
|
||||
bounds = QRectF()
|
||||
for element in icon.elements:
|
||||
if element.get("_deleted"):
|
||||
continue
|
||||
rect = QRectF(
|
||||
float(element.get("x", 0)),
|
||||
float(element.get("y", 0)),
|
||||
max(0.0, float(element.get("width", 0))),
|
||||
max(0.0, float(element.get("height", 0))),
|
||||
)
|
||||
bounds = rect if bounds.isNull() else bounds.united(rect)
|
||||
return bounds if not bounds.isNull() else QRectF(32, 32, 64, 64)
|
||||
|
||||
|
||||
def _pen(element: dict) -> QPen:
|
||||
styles = {
|
||||
"solid": Qt.PenStyle.SolidLine,
|
||||
@@ -19,11 +35,19 @@ def _pen(element: dict) -> QPen:
|
||||
)
|
||||
|
||||
|
||||
def paint_icon(painter: QPainter, icon: Icon, target: QRectF) -> None:
|
||||
def paint_icon(
|
||||
painter: QPainter,
|
||||
icon: Icon,
|
||||
target: QRectF,
|
||||
source: QRectF | None = None,
|
||||
) -> None:
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if source is None:
|
||||
source = QRectF(0, 0, icon.width, icon.height)
|
||||
painter.translate(target.topLeft())
|
||||
painter.scale(target.width() / icon.width, target.height() / icon.height)
|
||||
painter.scale(target.width() / source.width(), target.height() / source.height())
|
||||
painter.translate(-source.left(), -source.top())
|
||||
for element in icon.elements:
|
||||
kind = element.get("type", "rectangle")
|
||||
rect = QRectF(
|
||||
@@ -55,12 +79,20 @@ 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))
|
||||
bounds = icon_bounds(icon)
|
||||
ratio = min(size / bounds.width(), size / bounds.height())
|
||||
width, height = bounds.width() * ratio, bounds.height() * ratio
|
||||
paint_icon(
|
||||
painter,
|
||||
icon,
|
||||
QRectF((size - width) / 2, (size - height) / 2, width, height),
|
||||
bounds,
|
||||
)
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
def library_icon(icon: Icon) -> QIcon:
|
||||
return QIcon(icon_pixmap(icon, 16))
|
||||
# Render large enough for the tall Libraries rows. icon_pixmap crops to the
|
||||
# vector hitbox first, so a 32x32 drawing is shown as large as a 128x128 one.
|
||||
return QIcon(icon_pixmap(icon, 28))
|
||||
|
||||
@@ -12,6 +12,7 @@ from bedit.library.repository import LibraryRepository
|
||||
COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole + 2
|
||||
ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 3
|
||||
COMPONENT_INSTANCE_ROLE = Qt.ItemDataRole.UserRole + 4
|
||||
COMPONENT_MIME_TYPE = "application/x-bedit-component"
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ class LibraryTreeModel(QStandardItemModel):
|
||||
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)
|
||||
item.setData(component, COMPONENT_INSTANCE_ROLE)
|
||||
if component.show_subtree_in_library:
|
||||
for child in component.graph.blocks.values():
|
||||
item.appendRow(self._component_item(child, current=current))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QSettings, QSize, Qt, Slot
|
||||
@@ -7,16 +8,19 @@ from PySide6.QtWidgets import QFileDialog, QMainWindow, QMenu, QMessageBox, QToo
|
||||
|
||||
from bedit.component_options_dialog import ComponentOptionsDialog
|
||||
from bedit.document.controller import DocumentController
|
||||
from bedit.document.model import Port
|
||||
from bedit.document.model import Component, Port
|
||||
from bedit.document.serializer import JsonDocumentSerializer
|
||||
from bedit.item_options_dialog import ItemOptionsDialog
|
||||
from bedit.library.repository import LibraryRepository
|
||||
from bedit.library.tree_model import (
|
||||
COMPONENT_ID_ROLE,
|
||||
COMPONENT_INSTANCE_ROLE,
|
||||
ITEM_KIND_ROLE,
|
||||
DocumentTreeModel,
|
||||
LibraryTreeModel,
|
||||
)
|
||||
from bedit.settings_dialog import SettingsDialog
|
||||
from bedit.port_options_dialog import PortOptionsDialog
|
||||
from bedit.ui_main_window import Ui_MainWindow
|
||||
|
||||
|
||||
@@ -27,6 +31,10 @@ class MainWindow(QMainWindow):
|
||||
super().__init__()
|
||||
self.ui = Ui_MainWindow()
|
||||
self.ui.setupUi(self)
|
||||
self.ui.emptyPage.setStyleSheet("background-color: #9a9a9a;")
|
||||
self.ui.emptyWorkspaceLabel.setStyleSheet(
|
||||
"background: transparent; color: #202020;"
|
||||
)
|
||||
self._create_camera_toolbar()
|
||||
self.settings = QSettings()
|
||||
|
||||
@@ -59,10 +67,15 @@ 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.setIconSize(QSize(28, 28))
|
||||
self.ui.treeView.setStyleSheet("QTreeView::item { height: 32px; }")
|
||||
self.ui.treeView.setHeaderHidden(True)
|
||||
self.ui.treeView.setDragEnabled(True)
|
||||
self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
|
||||
self.ui.treeView.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.ui.treeView.customContextMenuRequested.connect(
|
||||
self.show_external_library_context_menu
|
||||
)
|
||||
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))
|
||||
@@ -81,6 +94,7 @@ class MainWindow(QMainWindow):
|
||||
self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll)
|
||||
self.ui.graphView.set_model(self.document_controller)
|
||||
self.ui.graphView.componentOptionsRequested.connect(self.show_component_options)
|
||||
self.ui.graphView.componentPortOptionsRequested.connect(self.show_component_port_options)
|
||||
self.ui.graphView.portOptionsRequested.connect(self.show_port_options)
|
||||
self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options)
|
||||
self.ui.graphView.selectionAvailabilityChanged.connect(
|
||||
@@ -88,9 +102,8 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
self.ui.navigateUpButton.clicked.connect(self.navigate_up)
|
||||
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
|
||||
self.ui.inputToolButton.clicked.connect(lambda: self.set_graph_tool("input"))
|
||||
self.ui.outputToolButton.clicked.connect(lambda: self.set_graph_tool("output"))
|
||||
self.ui.graphView.toolUsed.connect(lambda: self.set_graph_tool("pointer"))
|
||||
self.ui.inputToolButton.hide()
|
||||
self.ui.outputToolButton.hide()
|
||||
self.ui.applyJsonButton.clicked.connect(self.apply_json)
|
||||
self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
|
||||
|
||||
@@ -199,12 +212,9 @@ class MainWindow(QMainWindow):
|
||||
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
|
||||
self.ui.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage)
|
||||
self.ui.applyJsonButton.setVisible(not is_graph)
|
||||
for button in (
|
||||
self.ui.pointerToolButton,
|
||||
self.ui.inputToolButton,
|
||||
self.ui.outputToolButton,
|
||||
):
|
||||
button.setVisible(is_graph)
|
||||
self.ui.pointerToolButton.setVisible(is_graph)
|
||||
self.ui.inputToolButton.hide()
|
||||
self.ui.outputToolButton.hide()
|
||||
if is_graph:
|
||||
self.set_graph_tool("pointer")
|
||||
else:
|
||||
@@ -229,13 +239,8 @@ class MainWindow(QMainWindow):
|
||||
self.document_controller.navigate_up()
|
||||
|
||||
def set_graph_tool(self, mode: str) -> None:
|
||||
self.ui.graphView.set_tool_mode(mode)
|
||||
buttons = {
|
||||
"pointer": self.ui.pointerToolButton,
|
||||
"input": self.ui.inputToolButton,
|
||||
"output": self.ui.outputToolButton,
|
||||
}
|
||||
buttons[mode].setChecked(True)
|
||||
self.ui.graphView.set_tool_mode("pointer")
|
||||
self.ui.pointerToolButton.setChecked(True)
|
||||
|
||||
def _load_source_json(self) -> None:
|
||||
component = self.document_controller.active_component
|
||||
@@ -372,8 +377,15 @@ class MainWindow(QMainWindow):
|
||||
def show_settings(self) -> None:
|
||||
dialog = SettingsDialog(self)
|
||||
dialog.settingsChanged.connect(self.reload_libraries)
|
||||
dialog.settingsChanged.connect(self.refresh_editor_settings)
|
||||
dialog.exec()
|
||||
|
||||
def refresh_editor_settings(self) -> None:
|
||||
scene = self.ui.graphView.scene()
|
||||
if scene is not None:
|
||||
scene.update()
|
||||
self.ui.graphView.viewport().update()
|
||||
|
||||
def _document_opened_changed(self, opened: bool) -> None:
|
||||
for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs):
|
||||
action.setEnabled(opened)
|
||||
@@ -406,6 +418,7 @@ class MainWindow(QMainWindow):
|
||||
text_action = menu.addAction("New Text Block")
|
||||
menu.addSeparator()
|
||||
options_action = menu.addAction("Component Options…")
|
||||
ports_action = menu.addAction("Port Options…")
|
||||
delete_action = menu.addAction("Delete")
|
||||
selected = menu.exec(tree_view.viewport().mapToGlobal(position))
|
||||
if selected is graph_action:
|
||||
@@ -414,6 +427,8 @@ class MainWindow(QMainWindow):
|
||||
self.document_controller.add_child(component_id, "text")
|
||||
elif selected is options_action:
|
||||
self.show_component_options(component_id)
|
||||
elif selected is ports_action:
|
||||
self.show_component_port_options(component_id)
|
||||
elif selected is delete_action:
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
@@ -435,6 +450,56 @@ class MainWindow(QMainWindow):
|
||||
elif selected is text_action:
|
||||
self.document_controller.add_root("text")
|
||||
|
||||
@Slot(object)
|
||||
def show_external_library_context_menu(self, position) -> None:
|
||||
tree = self.ui.treeView
|
||||
index = tree.indexAt(position)
|
||||
component = index.data(COMPONENT_INSTANCE_ROLE)
|
||||
if not isinstance(component, Component):
|
||||
return
|
||||
menu = QMenu(self)
|
||||
ports_action = menu.addAction("Port Options…")
|
||||
if menu.exec(tree.viewport().mapToGlobal(position)) is ports_action:
|
||||
dialog = PortOptionsDialog(component, self)
|
||||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||||
old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs)
|
||||
component.inputs = dialog.inputs
|
||||
component.outputs = dialog.outputs
|
||||
library = next(
|
||||
(
|
||||
library
|
||||
for library in self.libraries.libraries
|
||||
if any(item is component for item in library.document.all_components())
|
||||
),
|
||||
None,
|
||||
)
|
||||
try:
|
||||
if library is not None:
|
||||
library.document.validate()
|
||||
JsonDocumentSerializer.save(
|
||||
library.document, Path(library.source_path)
|
||||
)
|
||||
except (OSError, ValueError) as error:
|
||||
component.inputs, component.outputs = old_inputs, old_outputs
|
||||
QMessageBox.warning(self, "Cannot change library ports", str(error))
|
||||
self.library_tree_model.rebuild()
|
||||
|
||||
@Slot(str)
|
||||
def show_component_port_options(self, component_id: str) -> None:
|
||||
document = self.document_controller.document
|
||||
component = document.find_component(component_id) if document else None
|
||||
if component is None:
|
||||
return
|
||||
dialog = PortOptionsDialog(component, self)
|
||||
if dialog.exec() != dialog.DialogCode.Accepted:
|
||||
return
|
||||
try:
|
||||
self.document_controller.edit_component_ports(
|
||||
component_id, dialog.inputs, dialog.outputs
|
||||
)
|
||||
except ValueError as error:
|
||||
QMessageBox.warning(self, "Cannot change ports", str(error))
|
||||
|
||||
@Slot(str)
|
||||
def show_component_options(self, component_id: str) -> None:
|
||||
document = self.document_controller.document
|
||||
|
||||
162
BEdit/src/bedit/port_options_dialog.py
Normal file
162
BEdit/src/bedit/port_options_dialog.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from copy import deepcopy
|
||||
from uuid import uuid4
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bedit.document.model import Component, Port
|
||||
from bedit.document.port_types import PortTypeRegistry
|
||||
|
||||
|
||||
PORT_ROLE = Qt.ItemDataRole.UserRole
|
||||
|
||||
|
||||
class PortOptionsDialog(QDialog):
|
||||
"""Unified editor for a component's typed, oriented ports."""
|
||||
|
||||
def __init__(self, component: Component, parent=None, *, read_only: bool = False) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"Port Options — {component.name}")
|
||||
self.resize(620, 380)
|
||||
self.ports: list[tuple[Port, str]] = [
|
||||
*((deepcopy(port), "input") for port in component.inputs),
|
||||
*((deepcopy(port), "output") for port in component.outputs),
|
||||
]
|
||||
self._loading = False
|
||||
layout = QVBoxLayout(self)
|
||||
splitter = QSplitter()
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
self.list = QListWidget()
|
||||
self.list.currentRowChanged.connect(self._load_current)
|
||||
left_layout.addWidget(self.list)
|
||||
port_buttons = QHBoxLayout()
|
||||
self.add_button = QPushButton("Add Port")
|
||||
self.remove_button = QPushButton("Remove Port")
|
||||
self.add_button.clicked.connect(self.add_port)
|
||||
self.remove_button.clicked.connect(self.remove_port)
|
||||
port_buttons.addWidget(self.add_button)
|
||||
port_buttons.addWidget(self.remove_button)
|
||||
left_layout.addLayout(port_buttons)
|
||||
right = QWidget()
|
||||
form = QFormLayout(right)
|
||||
self.name_edit = QLineEdit()
|
||||
self.type_combo = QComboBox()
|
||||
for port_type in PortTypeRegistry.all():
|
||||
self.type_combo.addItem(port_type.display_name, port_type.id)
|
||||
self.orientation_combo = QComboBox()
|
||||
self.orientation_combo.addItem("Input", "input")
|
||||
self.orientation_combo.addItem("Output", "output")
|
||||
form.addRow("Name:", self.name_edit)
|
||||
form.addRow("Type:", self.type_combo)
|
||||
form.addRow("Orientation:", self.orientation_combo)
|
||||
form.addRow("", QLabel("New ports start at (0, 0) in the icon editor."))
|
||||
self.name_edit.textEdited.connect(self._store_current)
|
||||
self.type_combo.currentIndexChanged.connect(self._store_current)
|
||||
self.orientation_combo.currentIndexChanged.connect(self._store_current)
|
||||
splitter.addWidget(left)
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([250, 370])
|
||||
layout.addWidget(splitter)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
if read_only:
|
||||
self.add_button.setEnabled(False)
|
||||
self.remove_button.setEnabled(False)
|
||||
self.name_edit.setReadOnly(True)
|
||||
self.type_combo.setEnabled(False)
|
||||
self.orientation_combo.setEnabled(False)
|
||||
buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Close")
|
||||
buttons.button(QDialogButtonBox.StandardButton.Cancel).hide()
|
||||
self._rebuild_list(0 if self.ports else -1)
|
||||
|
||||
@property
|
||||
def inputs(self) -> list[Port]:
|
||||
return [port for port, orientation in self.ports if orientation == "input"]
|
||||
|
||||
@property
|
||||
def outputs(self) -> list[Port]:
|
||||
return [port for port, orientation in self.ports if orientation == "output"]
|
||||
|
||||
def _rebuild_list(self, row: int = -1) -> None:
|
||||
self.list.clear()
|
||||
for port, orientation in self.ports:
|
||||
item = QListWidgetItem(f"{port.name} [{orientation}, {port.type}]")
|
||||
item.setData(PORT_ROLE, port.id)
|
||||
self.list.addItem(item)
|
||||
self.list.setCurrentRow(min(row, len(self.ports) - 1))
|
||||
self._update_enabled()
|
||||
|
||||
def _load_current(self, row: int) -> None:
|
||||
self._loading = True
|
||||
enabled = 0 <= row < len(self.ports)
|
||||
if enabled:
|
||||
port, orientation = self.ports[row]
|
||||
self.name_edit.setText(port.name)
|
||||
self.type_combo.setCurrentIndex(self.type_combo.findData(port.type))
|
||||
self.orientation_combo.setCurrentIndex(self.orientation_combo.findData(orientation))
|
||||
else:
|
||||
self.name_edit.clear()
|
||||
self._loading = False
|
||||
self._update_enabled()
|
||||
|
||||
def _update_enabled(self) -> None:
|
||||
enabled = self.list.currentRow() >= 0
|
||||
self.remove_button.setEnabled(enabled)
|
||||
self.name_edit.setEnabled(enabled)
|
||||
self.type_combo.setEnabled(enabled)
|
||||
self.orientation_combo.setEnabled(enabled)
|
||||
|
||||
def _store_current(self) -> None:
|
||||
row = self.list.currentRow()
|
||||
if self._loading or not (0 <= row < len(self.ports)):
|
||||
return
|
||||
port, _orientation = self.ports[row]
|
||||
port.name = self.name_edit.text()
|
||||
port.type = self.type_combo.currentData()
|
||||
self.ports[row] = (port, self.orientation_combo.currentData())
|
||||
self.list.item(row).setText(
|
||||
f"{port.name} [{self.ports[row][1]}, {port.type}]"
|
||||
)
|
||||
|
||||
def add_port(self) -> None:
|
||||
port = Port(
|
||||
id=f"port-{uuid4().hex[:8]}",
|
||||
name=f"Port {len(self.ports) + 1}",
|
||||
properties={"iconPosition": {"x": 0.0, "y": 0.0}},
|
||||
type="signal",
|
||||
)
|
||||
self.ports.append((port, "input"))
|
||||
self._rebuild_list(len(self.ports) - 1)
|
||||
self.name_edit.selectAll()
|
||||
self.name_edit.setFocus()
|
||||
|
||||
def remove_port(self) -> None:
|
||||
row = self.list.currentRow()
|
||||
if row >= 0:
|
||||
self.ports.pop(row)
|
||||
self._rebuild_list(min(row, len(self.ports) - 1))
|
||||
|
||||
def accept(self) -> None:
|
||||
self._store_current()
|
||||
if any(not port.name.strip() for port, _orientation in self.ports):
|
||||
QMessageBox.warning(self, "Invalid port", "Every port must have a name.")
|
||||
return
|
||||
super().accept()
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QSettings, Signal
|
||||
from PySide6.QtWidgets import QDialog, QFileDialog
|
||||
from PySide6.QtWidgets import QDialog, QFileDialog, QFormLayout, QGroupBox, QSpinBox
|
||||
|
||||
from bedit.library.repository import default_library_paths
|
||||
from bedit.ui_settings_dialog import Ui_SettingsDialog
|
||||
@@ -16,6 +16,17 @@ class SettingsDialog(QDialog):
|
||||
super().__init__(parent)
|
||||
self.ui = Ui_SettingsDialog()
|
||||
self.ui.setupUi(self)
|
||||
self.grid_group = QGroupBox("Editor grids", self.ui.generalTab)
|
||||
grid_form = QFormLayout(self.grid_group)
|
||||
self.graph_grid_spin = QSpinBox()
|
||||
self.graph_grid_spin.setRange(2, 256)
|
||||
self.graph_grid_spin.setSuffix(" units")
|
||||
self.icon_grid_spin = QSpinBox()
|
||||
self.icon_grid_spin.setRange(1, 64)
|
||||
self.icon_grid_spin.setSuffix(" units")
|
||||
grid_form.addRow("Graph grid size:", self.graph_grid_spin)
|
||||
grid_form.addRow("Icon grid size:", self.icon_grid_spin)
|
||||
self.ui.generalLayout.insertWidget(1, self.grid_group)
|
||||
self.settings = QSettings()
|
||||
self.ui.addLibraryFileButton.clicked.connect(self._add_library_file)
|
||||
self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder)
|
||||
@@ -32,6 +43,8 @@ class SettingsDialog(QDialog):
|
||||
)
|
||||
self.ui.libraryPathsList.clear()
|
||||
self.ui.libraryPathsList.addItems(self.library_paths(self.settings))
|
||||
self.graph_grid_spin.setValue(self.graph_grid_size(self.settings))
|
||||
self.icon_grid_spin.setValue(self.icon_grid_size(self.settings))
|
||||
self._update_remove_button()
|
||||
|
||||
@staticmethod
|
||||
@@ -42,6 +55,14 @@ class SettingsDialog(QDialog):
|
||||
return [value]
|
||||
return [str(path) for path in value]
|
||||
|
||||
@staticmethod
|
||||
def graph_grid_size(settings: QSettings | None = None) -> int:
|
||||
return (settings or QSettings()).value("grid/graphSize", 32, type=int)
|
||||
|
||||
@staticmethod
|
||||
def icon_grid_size(settings: QSettings | None = None) -> int:
|
||||
return (settings or QSettings()).value("grid/iconSize", 8, type=int)
|
||||
|
||||
def _add_library_file(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
@@ -83,6 +104,8 @@ class SettingsDialog(QDialog):
|
||||
for row in range(self.ui.libraryPathsList.count())
|
||||
]
|
||||
self.settings.setValue("libraries/paths", paths)
|
||||
self.settings.setValue("grid/graphSize", self.graph_grid_spin.value())
|
||||
self.settings.setValue("grid/iconSize", self.icon_grid_spin.value())
|
||||
self.settings.sync()
|
||||
self.settingsChanged.emit()
|
||||
super().accept()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtCore import QMimeData, QPointF, QRectF, QSettings, Qt, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QDragEnterEvent,
|
||||
@@ -24,18 +24,28 @@ from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QStyleOptionGraphicsItem,
|
||||
QToolTip,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
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
|
||||
from bedit.icon_renderer import icon_bounds, paint_icon
|
||||
|
||||
|
||||
SELECTION_MIME_TYPE = "application/x-bedit-selection"
|
||||
|
||||
|
||||
def _graph_grid_size() -> int:
|
||||
return QSettings().value("grid/graphSize", 32, type=int)
|
||||
|
||||
|
||||
def _snapped(position: QPointF) -> QPointF:
|
||||
grid = _graph_grid_size()
|
||||
return QPointF(round(position.x() / grid) * grid, round(position.y() / grid) * grid)
|
||||
|
||||
|
||||
class ConnectionPortItem(QGraphicsEllipseItem):
|
||||
def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None:
|
||||
super().__init__(-6, -6, 12, 12, parent)
|
||||
@@ -48,8 +58,8 @@ class ConnectionPortItem(QGraphicsEllipseItem):
|
||||
|
||||
|
||||
class ComponentGraphicsItem(QGraphicsObject):
|
||||
WIDTH = 120.0
|
||||
HEIGHT = 72.0
|
||||
WIDTH = 128.0
|
||||
HEIGHT = 128.0
|
||||
|
||||
def __init__(self, component: Component, controller: DocumentController) -> None:
|
||||
super().__init__()
|
||||
@@ -57,6 +67,7 @@ class ComponentGraphicsItem(QGraphicsObject):
|
||||
self.component = component
|
||||
self.controller = controller
|
||||
self.drag_start = QPointF()
|
||||
self.hitbox = icon_bounds(component.icon)
|
||||
self.setFlags(
|
||||
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
||||
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
||||
@@ -64,7 +75,7 @@ class ComponentGraphicsItem(QGraphicsObject):
|
||||
)
|
||||
self.input_ports = self._create_ports(component.inputs, "target", 0.0)
|
||||
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH)
|
||||
self.setTransformOriginPoint(self.WIDTH / 2, self.HEIGHT / 2)
|
||||
self.setTransformOriginPoint(self.hitbox.center())
|
||||
self.setRotation(component.rotation)
|
||||
|
||||
def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]:
|
||||
@@ -82,7 +93,7 @@ class ComponentGraphicsItem(QGraphicsObject):
|
||||
return result
|
||||
|
||||
def boundingRect(self) -> QRectF: # noqa: N802
|
||||
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
|
||||
return self.hitbox
|
||||
|
||||
def paint(
|
||||
self,
|
||||
@@ -91,7 +102,11 @@ class ComponentGraphicsItem(QGraphicsObject):
|
||||
widget: QWidget | None = None,
|
||||
) -> None:
|
||||
del option, widget
|
||||
paint_icon(painter, self.component.icon, self.boundingRect())
|
||||
paint_icon(
|
||||
painter,
|
||||
self.component.icon,
|
||||
QRectF(0, 0, self.WIDTH, self.HEIGHT),
|
||||
)
|
||||
if self.isSelected():
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor("#2563eb"), 2, Qt.PenStyle.DashLine))
|
||||
@@ -109,10 +124,16 @@ class ComponentGraphicsItem(QGraphicsObject):
|
||||
self.setSelected(True)
|
||||
menu = QMenu()
|
||||
options_action = menu.addAction("Component Options…")
|
||||
if menu.exec(event.screenPos()) is options_action:
|
||||
ports_action = menu.addAction("Port Options…")
|
||||
selected = menu.exec(event.screenPos())
|
||||
if selected is options_action:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.componentOptionsRequested.emit(self.component_id)
|
||||
elif selected is ports_action:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.componentPortOptionsRequested.emit(self.component_id)
|
||||
event.accept()
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
@@ -121,10 +142,14 @@ class ComponentGraphicsItem(QGraphicsObject):
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
super().mouseReleaseEvent(event)
|
||||
self.controller.move_component(self.component_id, self.drag_start, self.pos())
|
||||
snapped = _snapped(self.pos())
|
||||
self.setPos(snapped)
|
||||
self.controller.move_component(self.component_id, self.drag_start, snapped)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
value = _snapped(value)
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.update_connections_for_block(self.component_id)
|
||||
@@ -190,10 +215,14 @@ class InterfaceTerminalItem(QGraphicsObject):
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
super().mouseReleaseEvent(event)
|
||||
self.controller.move_interface_port(self.port.id, self.drag_start, self.pos())
|
||||
snapped = _snapped(self.pos())
|
||||
self.setPos(snapped)
|
||||
self.controller.move_interface_port(self.port.id, self.drag_start, snapped)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
value = _snapped(value)
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.update_connections_for_interface(self.port.id)
|
||||
@@ -236,6 +265,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
|
||||
|
||||
class GraphScene(QGraphicsScene):
|
||||
componentOptionsRequested = Signal(str)
|
||||
componentPortOptionsRequested = Signal(str)
|
||||
portOptionsRequested = Signal(str, str)
|
||||
connectionOptionsRequested = Signal(str)
|
||||
|
||||
@@ -308,16 +338,8 @@ class GraphScene(QGraphicsScene):
|
||||
self.update_connection(connection.id)
|
||||
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
||||
painter.fillRect(rect, QColor("#e4e4e4"))
|
||||
if self.controller.document is None or self.controller.active_component is None:
|
||||
return
|
||||
spacing = 32
|
||||
left = int(rect.left()) - (int(rect.left()) % spacing)
|
||||
top = int(rect.top()) - (int(rect.top()) % spacing)
|
||||
painter.setPen(QPen(QColor("#b8b8b8"), 1))
|
||||
for x in range(left, int(rect.right()) + spacing, spacing):
|
||||
for y in range(top, int(rect.bottom()) + spacing, spacing):
|
||||
painter.drawPoint(x, y)
|
||||
# The view paints the grid so it always covers the complete viewport.
|
||||
del painter, rect
|
||||
|
||||
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
|
||||
if endpoint.interface is not None:
|
||||
@@ -354,7 +376,10 @@ class GraphScene(QGraphicsScene):
|
||||
item.setBrush(QColor("#f5b642"))
|
||||
elif self.pending_source is not None:
|
||||
if self.pending_source.endpoint != item.endpoint:
|
||||
self.controller.connect(self.pending_source.endpoint, item.endpoint)
|
||||
try:
|
||||
self.controller.connect(self.pending_source.endpoint, item.endpoint)
|
||||
except ValueError as error:
|
||||
QToolTip.showText(event.screenPos(), str(error))
|
||||
self._clear_pending_source()
|
||||
event.accept()
|
||||
return
|
||||
@@ -370,6 +395,7 @@ class GraphScene(QGraphicsScene):
|
||||
class GraphWorkspaceView(QGraphicsView):
|
||||
toolUsed = Signal()
|
||||
componentOptionsRequested = Signal(str)
|
||||
componentPortOptionsRequested = Signal(str)
|
||||
portOptionsRequested = Signal(str, str)
|
||||
connectionOptionsRequested = Signal(str)
|
||||
selectionAvailabilityChanged = Signal(bool)
|
||||
@@ -382,10 +408,28 @@ class GraphWorkspaceView(QGraphicsView):
|
||||
self.setAcceptDrops(True)
|
||||
self.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
||||
self.setBackgroundBrush(QColor("#9a9a9a"))
|
||||
self.setBackgroundBrush(QColor("#f7f7f7"))
|
||||
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
||||
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
||||
"""Paint the visible graph viewport in scene coordinates."""
|
||||
painter.fillRect(rect, QColor("#f7f7f7"))
|
||||
if (
|
||||
self.controller is None
|
||||
or self.controller.document is None
|
||||
or self.controller.active_component is None
|
||||
):
|
||||
return
|
||||
spacing = _graph_grid_size()
|
||||
left = int(rect.left()) - (int(rect.left()) % spacing)
|
||||
top = int(rect.top()) - (int(rect.top()) % spacing)
|
||||
painter.setPen(QPen(QColor("#c5cbd1"), 0))
|
||||
for x in range(left, int(rect.right()) + spacing, spacing):
|
||||
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
|
||||
for y in range(top, int(rect.bottom()) + spacing, spacing):
|
||||
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
|
||||
|
||||
def _zoom(self, factor: float) -> None:
|
||||
current = self.transform().m11()
|
||||
target = current * factor
|
||||
@@ -417,6 +461,7 @@ class GraphWorkspaceView(QGraphicsView):
|
||||
self.controller = controller
|
||||
scene = GraphScene(controller, self)
|
||||
scene.componentOptionsRequested.connect(self.componentOptionsRequested)
|
||||
scene.componentPortOptionsRequested.connect(self.componentPortOptionsRequested)
|
||||
scene.portOptionsRequested.connect(self.portOptionsRequested)
|
||||
scene.connectionOptionsRequested.connect(self.connectionOptionsRequested)
|
||||
scene.selectionChanged.connect(
|
||||
@@ -530,15 +575,6 @@ class GraphWorkspaceView(QGraphicsView):
|
||||
)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
if (
|
||||
self.controller is not None
|
||||
and self.tool_mode in {"input", "output"}
|
||||
and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
self.controller.add_interface_port(self.tool_mode, self.mapToScene(event.position().toPoint()))
|
||||
self.toolUsed.emit()
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
|
||||
@@ -569,5 +605,7 @@ class GraphWorkspaceView(QGraphicsView):
|
||||
return
|
||||
data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8"))
|
||||
source = Component.from_dict(data)
|
||||
self.controller.add_component_copy(source, self.mapToScene(event.position().toPoint()))
|
||||
self.controller.add_component_copy(
|
||||
source, _snapped(self.mapToScene(event.position().toPoint()))
|
||||
)
|
||||
event.acceptProposedAction()
|
||||
|
||||
162
BEdit/test.bedit.json
Normal file
162
BEdit/test.bedit.json
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"format": "bedit-document",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"name": "Untitled"
|
||||
},
|
||||
"roots": [
|
||||
{
|
||||
"id": "5ee742db-b25c-4d86-b4ca-cc0e61c4002a",
|
||||
"name": "New Graph Block 1",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"interface": {
|
||||
"inputs": [],
|
||||
"outputs": []
|
||||
},
|
||||
"icon": {
|
||||
"shape": "rectangle",
|
||||
"fill": "#f4f4f4",
|
||||
"border": "#303030",
|
||||
"text": "Graph",
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 128.0
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"width": 126.0,
|
||||
"height": 126.0,
|
||||
"fill": "#f4f4f4",
|
||||
"stroke": "#303030",
|
||||
"lineWidth": 1.5,
|
||||
"lineStyle": "solid",
|
||||
"cornerRadius": 5.0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"x": 8.0,
|
||||
"y": 8.0,
|
||||
"width": 112.0,
|
||||
"height": 112.0,
|
||||
"text": "Graph",
|
||||
"color": "#202020",
|
||||
"fontSize": 12.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"properties": {},
|
||||
"library": {
|
||||
"showSubtree": true
|
||||
},
|
||||
"implementation": {
|
||||
"kind": "graph",
|
||||
"graph": {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "89944479-e73e-446a-8d58-ebf35ac4144b",
|
||||
"name": "New Graph Block 1",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": -96.0
|
||||
},
|
||||
"rotation": 0.0,
|
||||
"interface": {
|
||||
"inputs": [
|
||||
{
|
||||
"id": "port-e5cb81a5",
|
||||
"name": "Port 1",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"properties": {
|
||||
"iconPosition": {
|
||||
"x": 32.0,
|
||||
"y": 64.0
|
||||
}
|
||||
},
|
||||
"type": "signal"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "port-eb0034af",
|
||||
"name": "Port 2",
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
},
|
||||
"properties": {
|
||||
"iconPosition": {
|
||||
"x": 96.0,
|
||||
"y": 64.0
|
||||
}
|
||||
},
|
||||
"type": "signal"
|
||||
}
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"shape": "rectangle",
|
||||
"fill": "#f4f4f4",
|
||||
"border": "#303030",
|
||||
"text": "Graph",
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 128.0
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"x": 32.0,
|
||||
"y": 32.0,
|
||||
"width": 64.0,
|
||||
"height": 64.0,
|
||||
"fill": "#f4f4f4",
|
||||
"stroke": "#303030",
|
||||
"lineWidth": 1.5,
|
||||
"lineStyle": "solid",
|
||||
"cornerRadius": 5.0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"x": 40.0,
|
||||
"y": 40.0,
|
||||
"width": 48.0,
|
||||
"height": 48.0,
|
||||
"text": "Graph",
|
||||
"color": "#303030",
|
||||
"fontSize": 12.0,
|
||||
"lineStyle": "solid",
|
||||
"lineWidth": 1.5,
|
||||
"stroke": "#303030",
|
||||
"fill": "#ffffff"
|
||||
}
|
||||
]
|
||||
},
|
||||
"properties": {},
|
||||
"library": {
|
||||
"showSubtree": true
|
||||
},
|
||||
"implementation": {
|
||||
"kind": "graph",
|
||||
"graph": {
|
||||
"blocks": [],
|
||||
"connections": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user