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:
2026-07-19 22:06:36 +02:00
parent 09824195c9
commit dbca41640f
10 changed files with 551 additions and 59 deletions

View File

@@ -134,18 +134,18 @@ Every component owns its ports, declarative icon, properties, and child graph:
"name": "My Component", "name": "My Component",
"position": {"x": 0, "y": 0}, "position": {"x": 0, "y": 0},
"interface": { "interface": {
"inputs": [{"id": "in", "name": "Input", "properties": { "inputs": [{"id": "in", "name": "Input", "type": "signal", "properties": {
"iconPosition": {"x": 0, "y": 40} "iconPosition": {"x": 0, "y": 40}
}}], }}],
"outputs": [{"id": "out", "name": "Output", "properties": { "outputs": [{"id": "out", "name": "Output", "type": "signal", "properties": {
"iconPosition": {"x": 120, "y": 40} "iconPosition": {"x": 128, "y": 64}
}}] }}]
}, },
"icon": { "icon": {
"size": {"width": 120, "height": 80}, "size": {"width": 128, "height": 128},
"elements": [{ "elements": [{
"type": "rectangle", "x": 1, "y": 1, "type": "rectangle", "x": 1, "y": 1,
"width": 118, "height": 78, "width": 126, "height": 126,
"cornerRadius": 5, "cornerRadius": 5,
"fill": "#dbeafe", "stroke": "#245c9c", "fill": "#dbeafe", "stroke": "#245c9c",
"lineWidth": 1.5, "lineStyle": "solid" "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, never own a graph. Vector icons can contain rectangles, circles, ellipses,
lines, triangles, and text. Each element owns its geometry, fill, stroke, and lines, triangles, and text. Each element owns its geometry, fill, stroke, and
line style; ports keep their icon anchor in `properties.iconPosition`. The 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 recursive model is under `src/bedit/document/`, library loading
and the live Current Document tree are under `src/bedit/library/`, and graphics and the live Current Document tree are under `src/bedit/library/`, and graphics
are isolated under `src/bedit/workspace/`. are isolated under `src/bedit/workspace/`.

View File

@@ -29,6 +29,7 @@ from bedit.document.model import (
Port, Port,
clone_component, clone_component,
) )
from bedit.document.port_types import PortTypeRegistry
from bedit.document.serializer import JsonDocumentSerializer from bedit.document.serializer import JsonDocumentSerializer
@@ -227,12 +228,33 @@ class DocumentController(QObject):
def connect(self, source: Endpoint, target: Endpoint) -> str: def connect(self, source: Endpoint, target: Endpoint) -> str:
if self.active_component_id is None: if self.active_component_id is None:
raise ValueError("There is no active graph") 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) connection = Connection(str(uuid4()), source, target)
self.undo_stack.push( self.undo_stack.push(
AddConnectionCommand(self, self.active_component_id, connection) AddConnectionCommand(self, self.active_component_id, connection)
) )
return connection.id 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: def add_interface_port(self, direction: str, position: QPointF) -> str:
component = self.active_component component = self.active_component
if component is None or component.implementation_kind != "graph": if component is None or component.implementation_kind != "graph":
@@ -354,6 +376,55 @@ class DocumentController(QObject):
"outputs": [port.to_dict() for port in outputs], "outputs": [port.to_dict() for port in outputs],
"show_subtree": show_subtree, "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: if old != new:
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new)) self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))

View File

@@ -5,6 +5,8 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from bedit.document.port_types import PortTypeRegistry
@dataclass @dataclass
class Port: class Port:
@@ -13,6 +15,7 @@ class Port:
x: float = 0.0 x: float = 0.0
y: float = 0.0 y: float = 0.0
properties: dict[str, Any] = field(default_factory=dict) properties: dict[str, Any] = field(default_factory=dict)
type: str = "signal"
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -20,6 +23,7 @@ class Port:
"name": self.name, "name": self.name,
"position": {"x": self.x, "y": self.y}, "position": {"x": self.x, "y": self.y},
"properties": self.properties, "properties": self.properties,
"type": self.type,
} }
@classmethod @classmethod
@@ -31,6 +35,7 @@ class Port:
x=float(position.get("x", 0.0)), x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)), y=float(position.get("y", 0.0)),
properties=dict(data.get("properties", {})), properties=dict(data.get("properties", {})),
type=str(data.get("type", "signal")),
) )
@@ -40,8 +45,8 @@ class Icon:
fill: str = "#f4f4f4" fill: str = "#f4f4f4"
border: str = "#303030" border: str = "#303030"
text: str = "" text: str = ""
width: float = 120.0 width: float = 128.0
height: float = 80.0 height: float = 128.0
elements: list[dict[str, Any]] = field(default_factory=list) elements: list[dict[str, Any]] = field(default_factory=list)
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -72,14 +77,13 @@ class Icon:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Icon": def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
data = data or {} data = data or {}
size = data.get("size", {})
icon = cls( icon = cls(
shape=str(data.get("shape", "rectangle")), shape=str(data.get("shape", "rectangle")),
fill=str(data.get("fill", "#f4f4f4")), fill=str(data.get("fill", "#f4f4f4")),
border=str(data.get("border", "#303030")), border=str(data.get("border", "#303030")),
text=str(data.get("text", "")), text=str(data.get("text", "")),
width=float(size.get("width", 120.0)), width=128.0,
height=float(size.get("height", 80.0)), height=128.0,
elements=deepcopy(data.get("elements", [])), elements=deepcopy(data.get("elements", [])),
) )
return icon return icon
@@ -293,21 +297,31 @@ class GraphDocument:
def _validate_graph(owner: Component) -> None: def _validate_graph(owner: Component) -> None:
input_ids = {port.id for port in owner.inputs} input_ids = {port.id for port in owner.inputs}
output_ids = {port.id for port in owner.outputs} 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(): for connection in owner.graph.connections.values():
if connection.source.interface is not None: if connection.source.interface is not None:
if connection.source.interface not in input_ids: if connection.source.interface not in input_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface input") 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: else:
source = owner.graph.blocks.get(connection.source.block or "") 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}: 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") 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 is not None:
if connection.target.interface not in output_ids: if connection.target.interface not in output_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface output") 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: else:
target = owner.graph.blocks.get(connection.target.block or "") 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}: 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") 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: def clone_component(source: Component) -> Component:
@@ -341,8 +355,8 @@ def clone_component(source: Component) -> Component:
name=current.name, name=current.name,
x=current.x, x=current.x,
y=current.y, y=current.y,
inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs], inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) for port in current.inputs],
outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.outputs], outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties), port.type) for port in current.outputs],
icon=Icon.from_dict(current.icon.to_dict()), icon=Icon.from_dict(current.icon.to_dict()),
properties=deepcopy(current.properties), properties=deepcopy(current.properties),
implementation_kind=current.implementation_kind, implementation_kind=current.implementation_kind,

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

View File

@@ -1,6 +1,6 @@
from copy import deepcopy 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.QtGui import QColor, QPainter, QPen, QPolygonF
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QColorDialog, QColorDialog,
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QGraphicsObject, QGraphicsObject,
QGraphicsScene, QGraphicsScene,
QGraphicsSceneContextMenuEvent, QGraphicsSceneContextMenuEvent,
QGraphicsSceneMouseEvent,
QGraphicsView, QGraphicsView,
QHBoxLayout, QHBoxLayout,
QInputDialog, QInputDialog,
@@ -30,6 +31,60 @@ from bedit.document.model import Component, Icon, Port
from bedit.icon_renderer import _pen 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): class ColorButton(QPushButton):
def __init__(self, color: str, allow_none: bool = False, parent=None) -> None: def __init__(self, color: str, allow_none: bool = False, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -128,6 +183,9 @@ class ShapeItem(QGraphicsObject):
self.element = element self.element = element
self.setPos(float(element.get("x", 0)), float(element.get("y", 0))) self.setPos(float(element.get("x", 0)), float(element.get("y", 0)))
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) 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 def boundingRect(self) -> QRectF: # noqa: N802
margin = max(3.0, float(self.element.get("lineWidth", 1.5))) margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
@@ -161,10 +219,38 @@ class ShapeItem(QGraphicsObject):
painter.drawRect(rect) painter.drawRect(rect)
def itemChange(self, change, value): # noqa: N802 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() 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) 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 def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
menu = QMenu() menu = QMenu()
options = menu.addAction("Shape Options…") options = menu.addAction("Shape Options…")
@@ -176,6 +262,10 @@ class ShapeItem(QGraphicsObject):
self.prepareGeometryChange() self.prepareGeometryChange()
self.element.clear() self.element.clear()
self.element.update(dialog.element) self.element.update(dialog.element)
self.resize_handle.setPos(
float(self.element.get("width", 20)),
float(self.element.get("height", 20)),
)
self.update() self.update()
elif chosen is delete and self.scene() is not None: elif chosen is delete and self.scene() is not None:
self.scene().removeItem(self) self.scene().removeItem(self)
@@ -195,10 +285,24 @@ class PortHandle(QGraphicsEllipseItem):
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change, value): # noqa: N802 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()} self.port.properties["iconPosition"] = {"x": value.x(), "y": value.y()}
return super().itemChange(change, 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(
min(bounds.right(), max(bounds.left(), _snap(self.pos().x()))),
min(bounds.bottom(), max(bounds.top(), _snap(self.pos().y()))),
)
class IconEditorDialog(QDialog): class IconEditorDialog(QDialog):
def __init__(self, component: Component, parent=None) -> None: def __init__(self, component: Component, parent=None) -> None:
@@ -222,11 +326,10 @@ class IconEditorDialog(QDialog):
toolbar.addWidget(delete) toolbar.addWidget(delete)
layout.addLayout(toolbar) layout.addLayout(toolbar)
self.scene = QGraphicsScene(0, 0, self.icon.width, self.icon.height, self) 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.setRenderHint(QPainter.RenderHint.Antialiasing)
self.view.setBackgroundBrush(QColor("#f8fafc"))
self.view.setDragMode(QGraphicsView.DragMode.RubberBandDrag) 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(self.view, 1)
layout.addWidget(QLabel("Green points are inputs; red points are outputs. Drag them to place connection anchors.")) layout.addWidget(QLabel("Green points are inputs; red points are outputs. Drag them to place connection anchors."))
for element in self.icon.elements: for element in self.icon.elements:

View File

@@ -12,6 +12,7 @@ from bedit.library.repository import LibraryRepository
COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1 COMPONENT_ROLE = Qt.ItemDataRole.UserRole + 1
COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole + 2 COMPONENT_ID_ROLE = Qt.ItemDataRole.UserRole + 2
ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 3 ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 3
COMPONENT_INSTANCE_ROLE = Qt.ItemDataRole.UserRole + 4
COMPONENT_MIME_TYPE = "application/x-bedit-component" COMPONENT_MIME_TYPE = "application/x-bedit-component"
@@ -48,6 +49,7 @@ class LibraryTreeModel(QStandardItemModel):
item.setData(component.to_dict(), COMPONENT_ROLE) item.setData(component.to_dict(), COMPONENT_ROLE)
item.setData(component.id, COMPONENT_ID_ROLE) item.setData(component.id, COMPONENT_ID_ROLE)
item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE) item.setData("current-component" if current else "library-component", ITEM_KIND_ROLE)
item.setData(component, COMPONENT_INSTANCE_ROLE)
if component.show_subtree_in_library: if component.show_subtree_in_library:
for child in component.graph.blocks.values(): for child in component.graph.blocks.values():
item.appendRow(self._component_item(child, current=current)) item.appendRow(self._component_item(child, current=current))

View File

@@ -1,4 +1,5 @@
import json import json
from copy import deepcopy
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QSettings, QSize, Qt, Slot 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.component_options_dialog import ComponentOptionsDialog
from bedit.document.controller import DocumentController 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.item_options_dialog import ItemOptionsDialog
from bedit.library.repository import LibraryRepository from bedit.library.repository import LibraryRepository
from bedit.library.tree_model import ( from bedit.library.tree_model import (
COMPONENT_ID_ROLE, COMPONENT_ID_ROLE,
COMPONENT_INSTANCE_ROLE,
ITEM_KIND_ROLE, ITEM_KIND_ROLE,
DocumentTreeModel, DocumentTreeModel,
LibraryTreeModel, LibraryTreeModel,
) )
from bedit.settings_dialog import SettingsDialog from bedit.settings_dialog import SettingsDialog
from bedit.port_options_dialog import PortOptionsDialog
from bedit.ui_main_window import Ui_MainWindow from bedit.ui_main_window import Ui_MainWindow
@@ -63,6 +67,10 @@ class MainWindow(QMainWindow):
self.ui.treeView.setHeaderHidden(True) self.ui.treeView.setHeaderHidden(True)
self.ui.treeView.setDragEnabled(True) self.ui.treeView.setDragEnabled(True)
self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly) self.ui.treeView.setDragDropMode(self.ui.treeView.DragDropMode.DragOnly)
self.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.library_tree_model.rebuilt.connect(self.ui.treeView.expandAll)
self.ui.documentTreeView.setModel(self.document_tree_model) self.ui.documentTreeView.setModel(self.document_tree_model)
self.ui.documentTreeView.setIconSize(QSize(16, 16)) self.ui.documentTreeView.setIconSize(QSize(16, 16))
@@ -81,6 +89,7 @@ class MainWindow(QMainWindow):
self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll) self.document_tree_model.rebuilt.connect(self.ui.documentTreeView.expandAll)
self.ui.graphView.set_model(self.document_controller) self.ui.graphView.set_model(self.document_controller)
self.ui.graphView.componentOptionsRequested.connect(self.show_component_options) 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.portOptionsRequested.connect(self.show_port_options)
self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options) self.ui.graphView.connectionOptionsRequested.connect(self.show_connection_options)
self.ui.graphView.selectionAvailabilityChanged.connect( self.ui.graphView.selectionAvailabilityChanged.connect(
@@ -88,9 +97,8 @@ class MainWindow(QMainWindow):
) )
self.ui.navigateUpButton.clicked.connect(self.navigate_up) self.ui.navigateUpButton.clicked.connect(self.navigate_up)
self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer")) self.ui.pointerToolButton.clicked.connect(lambda: self.set_graph_tool("pointer"))
self.ui.inputToolButton.clicked.connect(lambda: self.set_graph_tool("input")) self.ui.inputToolButton.hide()
self.ui.outputToolButton.clicked.connect(lambda: self.set_graph_tool("output")) self.ui.outputToolButton.hide()
self.ui.graphView.toolUsed.connect(lambda: self.set_graph_tool("pointer"))
self.ui.applyJsonButton.clicked.connect(self.apply_json) self.ui.applyJsonButton.clicked.connect(self.apply_json)
self.document_controller.activeGraphChanged.connect(self._active_graph_changed) self.document_controller.activeGraphChanged.connect(self._active_graph_changed)
@@ -199,12 +207,9 @@ class MainWindow(QMainWindow):
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text") 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.workspaceStack.setCurrentWidget(self.ui.graphPage if is_graph else self.ui.jsonPage)
self.ui.applyJsonButton.setVisible(not is_graph) self.ui.applyJsonButton.setVisible(not is_graph)
for button in ( self.ui.pointerToolButton.setVisible(is_graph)
self.ui.pointerToolButton, self.ui.inputToolButton.hide()
self.ui.inputToolButton, self.ui.outputToolButton.hide()
self.ui.outputToolButton,
):
button.setVisible(is_graph)
if is_graph: if is_graph:
self.set_graph_tool("pointer") self.set_graph_tool("pointer")
else: else:
@@ -229,13 +234,8 @@ class MainWindow(QMainWindow):
self.document_controller.navigate_up() self.document_controller.navigate_up()
def set_graph_tool(self, mode: str) -> None: def set_graph_tool(self, mode: str) -> None:
self.ui.graphView.set_tool_mode(mode) self.ui.graphView.set_tool_mode("pointer")
buttons = { self.ui.pointerToolButton.setChecked(True)
"pointer": self.ui.pointerToolButton,
"input": self.ui.inputToolButton,
"output": self.ui.outputToolButton,
}
buttons[mode].setChecked(True)
def _load_source_json(self) -> None: def _load_source_json(self) -> None:
component = self.document_controller.active_component component = self.document_controller.active_component
@@ -372,8 +372,14 @@ class MainWindow(QMainWindow):
def show_settings(self) -> None: def show_settings(self) -> None:
dialog = SettingsDialog(self) dialog = SettingsDialog(self)
dialog.settingsChanged.connect(self.reload_libraries) dialog.settingsChanged.connect(self.reload_libraries)
dialog.settingsChanged.connect(self.refresh_editor_settings)
dialog.exec() 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: def _document_opened_changed(self, opened: bool) -> None:
for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs): for action in (self.ui.actionClose, self.ui.actionSave, self.ui.actionSaveAs):
action.setEnabled(opened) action.setEnabled(opened)
@@ -406,6 +412,7 @@ class MainWindow(QMainWindow):
text_action = menu.addAction("New Text Block") text_action = menu.addAction("New Text Block")
menu.addSeparator() menu.addSeparator()
options_action = menu.addAction("Component Options…") options_action = menu.addAction("Component Options…")
ports_action = menu.addAction("Port Options…")
delete_action = menu.addAction("Delete") delete_action = menu.addAction("Delete")
selected = menu.exec(tree_view.viewport().mapToGlobal(position)) selected = menu.exec(tree_view.viewport().mapToGlobal(position))
if selected is graph_action: if selected is graph_action:
@@ -414,6 +421,8 @@ class MainWindow(QMainWindow):
self.document_controller.add_child(component_id, "text") self.document_controller.add_child(component_id, "text")
elif selected is options_action: elif selected is options_action:
self.show_component_options(component_id) self.show_component_options(component_id)
elif selected is ports_action:
self.show_component_port_options(component_id)
elif selected is delete_action: elif selected is delete_action:
answer = QMessageBox.question( answer = QMessageBox.question(
self, self,
@@ -435,6 +444,56 @@ class MainWindow(QMainWindow):
elif selected is text_action: elif selected is text_action:
self.document_controller.add_root("text") 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) @Slot(str)
def show_component_options(self, component_id: str) -> None: def show_component_options(self, component_id: str) -> None:
document = self.document_controller.document document = self.document_controller.document

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

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QSettings, Signal 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.library.repository import default_library_paths
from bedit.ui_settings_dialog import Ui_SettingsDialog from bedit.ui_settings_dialog import Ui_SettingsDialog
@@ -16,6 +16,17 @@ class SettingsDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.ui = Ui_SettingsDialog() self.ui = Ui_SettingsDialog()
self.ui.setupUi(self) 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.settings = QSettings()
self.ui.addLibraryFileButton.clicked.connect(self._add_library_file) self.ui.addLibraryFileButton.clicked.connect(self._add_library_file)
self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder) self.ui.addLibraryFolderButton.clicked.connect(self._add_library_folder)
@@ -32,6 +43,8 @@ class SettingsDialog(QDialog):
) )
self.ui.libraryPathsList.clear() self.ui.libraryPathsList.clear()
self.ui.libraryPathsList.addItems(self.library_paths(self.settings)) 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() self._update_remove_button()
@staticmethod @staticmethod
@@ -42,6 +55,14 @@ class SettingsDialog(QDialog):
return [value] return [value]
return [str(path) for path in 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: def _add_library_file(self) -> None:
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self, self,
@@ -83,6 +104,8 @@ class SettingsDialog(QDialog):
for row in range(self.ui.libraryPathsList.count()) for row in range(self.ui.libraryPathsList.count())
] ]
self.settings.setValue("libraries/paths", paths) 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.settings.sync()
self.settingsChanged.emit() self.settingsChanged.emit()
super().accept() super().accept()

View File

@@ -1,6 +1,6 @@
import json 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 ( from PySide6.QtGui import (
QColor, QColor,
QDragEnterEvent, QDragEnterEvent,
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
QApplication, QApplication,
QMenu, QMenu,
QStyleOptionGraphicsItem, QStyleOptionGraphicsItem,
QToolTip,
QWidget, QWidget,
) )
@@ -36,6 +37,15 @@ from bedit.icon_renderer import paint_icon
SELECTION_MIME_TYPE = "application/x-bedit-selection" 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): class ConnectionPortItem(QGraphicsEllipseItem):
def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None: def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None:
super().__init__(-6, -6, 12, 12, parent) super().__init__(-6, -6, 12, 12, parent)
@@ -48,8 +58,8 @@ class ConnectionPortItem(QGraphicsEllipseItem):
class ComponentGraphicsItem(QGraphicsObject): class ComponentGraphicsItem(QGraphicsObject):
WIDTH = 120.0 WIDTH = 128.0
HEIGHT = 72.0 HEIGHT = 128.0
def __init__(self, component: Component, controller: DocumentController) -> None: def __init__(self, component: Component, controller: DocumentController) -> None:
super().__init__() super().__init__()
@@ -109,10 +119,16 @@ class ComponentGraphicsItem(QGraphicsObject):
self.setSelected(True) self.setSelected(True)
menu = QMenu() menu = QMenu()
options_action = menu.addAction("Component Options…") 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() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
scene.componentOptionsRequested.emit(self.component_id) 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() event.accept()
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
@@ -121,10 +137,14 @@ class ComponentGraphicsItem(QGraphicsObject):
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event) 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 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() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
scene.update_connections_for_block(self.component_id) scene.update_connections_for_block(self.component_id)
@@ -190,10 +210,14 @@ class InterfaceTerminalItem(QGraphicsObject):
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802 def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
super().mouseReleaseEvent(event) 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 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() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
scene.update_connections_for_interface(self.port.id) scene.update_connections_for_interface(self.port.id)
@@ -236,6 +260,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
class GraphScene(QGraphicsScene): class GraphScene(QGraphicsScene):
componentOptionsRequested = Signal(str) componentOptionsRequested = Signal(str)
componentPortOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str) portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str) connectionOptionsRequested = Signal(str)
@@ -311,13 +336,14 @@ class GraphScene(QGraphicsScene):
painter.fillRect(rect, QColor("#e4e4e4")) painter.fillRect(rect, QColor("#e4e4e4"))
if self.controller.document is None or self.controller.active_component is None: if self.controller.document is None or self.controller.active_component is None:
return return
spacing = 32 spacing = _graph_grid_size()
left = int(rect.left()) - (int(rect.left()) % spacing) left = int(rect.left()) - (int(rect.left()) % spacing)
top = int(rect.top()) - (int(rect.top()) % 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 x in range(left, int(rect.right()) + spacing, spacing):
for y in range(top, int(rect.bottom()) + spacing, spacing): painter.drawLine(x, rect.top(), x, rect.bottom())
painter.drawPoint(x, y) 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: def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
if endpoint.interface is not None: if endpoint.interface is not None:
@@ -354,7 +380,10 @@ class GraphScene(QGraphicsScene):
item.setBrush(QColor("#f5b642")) item.setBrush(QColor("#f5b642"))
elif self.pending_source is not None: elif self.pending_source is not None:
if self.pending_source.endpoint != item.endpoint: 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() self._clear_pending_source()
event.accept() event.accept()
return return
@@ -370,6 +399,7 @@ class GraphScene(QGraphicsScene):
class GraphWorkspaceView(QGraphicsView): class GraphWorkspaceView(QGraphicsView):
toolUsed = Signal() toolUsed = Signal()
componentOptionsRequested = Signal(str) componentOptionsRequested = Signal(str)
componentPortOptionsRequested = Signal(str)
portOptionsRequested = Signal(str, str) portOptionsRequested = Signal(str, str)
connectionOptionsRequested = Signal(str) connectionOptionsRequested = Signal(str)
selectionAvailabilityChanged = Signal(bool) selectionAvailabilityChanged = Signal(bool)
@@ -417,6 +447,7 @@ class GraphWorkspaceView(QGraphicsView):
self.controller = controller self.controller = controller
scene = GraphScene(controller, self) scene = GraphScene(controller, self)
scene.componentOptionsRequested.connect(self.componentOptionsRequested) scene.componentOptionsRequested.connect(self.componentOptionsRequested)
scene.componentPortOptionsRequested.connect(self.componentPortOptionsRequested)
scene.portOptionsRequested.connect(self.portOptionsRequested) scene.portOptionsRequested.connect(self.portOptionsRequested)
scene.connectionOptionsRequested.connect(self.connectionOptionsRequested) scene.connectionOptionsRequested.connect(self.connectionOptionsRequested)
scene.selectionChanged.connect( scene.selectionChanged.connect(
@@ -530,15 +561,6 @@ class GraphWorkspaceView(QGraphicsView):
) )
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 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) super().mousePressEvent(event)
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
@@ -569,5 +591,7 @@ class GraphWorkspaceView(QGraphicsView):
return return
data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8")) data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8"))
source = Component.from_dict(data) 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() event.acceptProposedAction()