Implemented the new port-management and grid workflow.
Right-click blocks in the graph, Document tree, or Libraries tree and choose “Port Options…”. Unified port list with:Add/remove port Name Type (Signal currently) Input/output orientation New ports start at (0, 0) in the icon editor. Connected ports cannot be removed, reoriented, or changed incompatibly. Added a PortType registry; connections require matching port types. Removed/hid the separate Add Input and Add Output workspace tools. Library port changes are written back to the library JSON. Added independent graph and icon grid-size settings. Components, interface terminals, icon shapes, resize handles, and icon ports snap to their corresponding grid. Icon canvases and component hitboxes are now fixed at 128×128. Selected icon shapes show a bottom-right resize handle. Circles preserve equal width and height while resizing. Shapes and ports are constrained to the icon hitbox. Fixed the icon-editor crash and grid behavior. Renamed the resize handle’s shape attribute, which was overriding Qt’s required shape() method. Icon shapes, resize handles, and port anchors now snap while dragging. Graph blocks and interface terminals also snap while dragging. Replaced the nearly invisible dotted graph grid with higher-contrast grid lines. Retained final release-time snapping as a safety check. Python compilation and diff validation pass.
This commit is contained in:
@@ -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,
|
||||
"width": 126, "height": 126,
|
||||
"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/`.
|
||||
|
||||
@@ -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,8 +45,8 @@ 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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -63,6 +67,10 @@ class MainWindow(QMainWindow):
|
||||
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 +89,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 +97,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 +207,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 +234,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 +372,14 @@ 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()
|
||||
|
||||
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 +412,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 +421,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 +444,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,6 +24,7 @@ from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QStyleOptionGraphicsItem,
|
||||
QToolTip,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
@@ -36,6 +37,15 @@ from bedit.icon_renderer import 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__()
|
||||
@@ -109,10 +119,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 +137,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 +210,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 +260,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
|
||||
|
||||
class GraphScene(QGraphicsScene):
|
||||
componentOptionsRequested = Signal(str)
|
||||
componentPortOptionsRequested = Signal(str)
|
||||
portOptionsRequested = Signal(str, str)
|
||||
connectionOptionsRequested = Signal(str)
|
||||
|
||||
@@ -311,13 +336,14 @@ class GraphScene(QGraphicsScene):
|
||||
painter.fillRect(rect, QColor("#e4e4e4"))
|
||||
if self.controller.document is None or self.controller.active_component is None:
|
||||
return
|
||||
spacing = 32
|
||||
spacing = _graph_grid_size()
|
||||
left = int(rect.left()) - (int(rect.left()) % spacing)
|
||||
top = int(rect.top()) - (int(rect.top()) % spacing)
|
||||
painter.setPen(QPen(QColor("#b8b8b8"), 1))
|
||||
painter.setPen(QPen(QColor("#b9c0c7"), 0))
|
||||
for x in range(left, int(rect.right()) + spacing, spacing):
|
||||
for y in range(top, int(rect.bottom()) + spacing, spacing):
|
||||
painter.drawPoint(x, y)
|
||||
painter.drawLine(x, rect.top(), x, rect.bottom())
|
||||
for y in range(top, int(rect.bottom()) + spacing, spacing):
|
||||
painter.drawLine(rect.left(), y, rect.right(), y)
|
||||
|
||||
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
|
||||
if endpoint.interface is not None:
|
||||
@@ -354,7 +380,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 +399,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)
|
||||
@@ -417,6 +447,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 +561,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 +591,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()
|
||||
|
||||
Reference in New Issue
Block a user